diff --git a/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/EagleGameModel.cs b/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/EagleGameModel.cs index 262db91e91..8d45d4e607 100644 --- a/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/EagleGameModel.cs +++ b/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/EagleGameModel.cs @@ -88,10 +88,10 @@ namespace eagle { public Action NoteRecipient { get; set; } /// - /// Callback to check if there's a pending command with the specified token. + /// Callback to check if a command with the specified token is pending or was acknowledged. /// Set by PersistentClientConnection. When this returns true, HandleAvailableCommands /// will set the token (for TryPendingCommands matching) but not load the commands, - /// since the pending command will be posted and we'll get fresh commands after. + /// since that command is already in flight or accepted and we'll get fresh commands after. /// public Func HasPendingCommandWithToken { get; set; } @@ -863,10 +863,9 @@ namespace eagle { _currentModel.CommandToken = availableCommands.Token; - // Check if there's a pending command with this token waiting to be posted. - // If so, don't load the commands - they're stale (we already acted on them). - // The pending command will be posted by TryPendingCommands, and we'll get - // fresh commands after it's processed. + // Check if a command with this token is pending or was already acknowledged. + // If so, don't load the commands: this response is stale because we already acted + // on this token. A fresh command set will arrive after the post is processed. if (HasPendingCommandWithToken != null && HasPendingCommandWithToken(availableCommands.Token)) { _connectionLogger.LogLine( diff --git a/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/IClientConnectionSubscriber.cs b/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/IClientConnectionSubscriber.cs index fc2aaaff73..f389aa99b6 100644 --- a/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/IClientConnectionSubscriber.cs +++ b/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/IClientConnectionSubscriber.cs @@ -10,9 +10,8 @@ namespace eagle { public Int64? CurrentShardokToken(string shardokGameId); /// - /// Callback to check if there's a pending command with the specified token. - /// Set by PersistentClientConnection. Used to skip loading stale commands - /// when a pending command is about to be posted. + /// Callback to check if a command with the specified token is pending or was acknowledged. + /// Set by PersistentClientConnection and used to suppress stale command-set responses. /// public Func HasPendingCommandWithToken { get; set; } diff --git a/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/PersistentClientConnection.cs b/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/PersistentClientConnection.cs index f51b475fe8..bbc8081216 100644 --- a/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/PersistentClientConnection.cs +++ b/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/PersistentClientConnection.cs @@ -216,6 +216,7 @@ namespace eagle { private CancellationToken _currentThreadToken; private List _pendingCommands = new(); + private readonly Dictionary _acknowledgedEagleCommandTokens = new(); // Serializes TryPendingCommands invocations. Multiple MainQueue actions (one per // ActionResultResponse / ShardokActionResultResponse) each call TryPendingCommands, @@ -228,12 +229,22 @@ namespace eagle { private readonly SemaphoreSlim _tryPendingCommandsSemaphore = new(1, 1); /// - /// Checks if there's a pending Eagle command with the specified token for the given game. - /// Used by GameModelUpdater to skip loading stale commands when a pending command - /// is about to be posted. + /// Checks if an Eagle command with the specified token is pending or has already been + /// acknowledged by the server. GameModelUpdater uses this to suppress a stale command-set + /// response that can race with the result of a successful post. /// public bool HasPendingEagleCommandWithToken(long gameId, long token) { lock (this) { + if (_acknowledgedEagleCommandTokens.TryGetValue( + gameId, + out var acknowledgedToken)) { + if (acknowledgedToken == token) { return true; } + + // A different server token proves that this acknowledgement fence is no + // longer needed, whether the game advanced normally or was rewound. + _acknowledgedEagleCommandTokens.Remove(gameId); + } + foreach (var cmd in _pendingCommands) { if (cmd.GameId == gameId && cmd.Command.SealedValueCase == @@ -394,12 +405,24 @@ namespace eagle { } /// - /// Remove all pending commands for a specific game. - /// Called when the server confirms a command was processed (SUCCESS or BAD_TOKEN). + /// Remove all pending commands for a specific game. Successful Eagle command tokens are + /// retained as a short-lived acknowledgement fence so a stale stream update cannot + /// restore commands for a command the server has already accepted. /// - private void RemovePendingCommandsForGame(long gameId) { + private void RemovePendingCommandsForGame( + long gameId, + bool rememberAcknowledgedEagleToken = false) { lock (this) { var toRemove = _pendingCommands.Where(cmd => cmd.GameId == gameId).ToList(); + if (rememberAcknowledgedEagleToken) { + var acknowledgedEagleCommand = toRemove.LastOrDefault( + cmd => cmd.Command.SealedValueCase == + UniversalCommand.SealedValueOneofCase.EagleCommand); + if (acknowledgedEagleCommand != null) { + _acknowledgedEagleCommandTokens[gameId] = + acknowledgedEagleCommand.Command.EagleCommand.EagleToken; + } + } foreach (var cmd in toRemove) { _pendingCommands.Remove(cmd); } if (toRemove.Count > 0) { _remoteEagleClientLogger.LogLine( @@ -811,6 +834,7 @@ namespace eagle { // Clear pending commands _pendingCommands.Clear(); + _acknowledgedEagleCommandTokens.Clear(); OnChannelDead = null; OnCommandError = null; @@ -1446,7 +1470,9 @@ namespace eagle { _remoteEagleClientLogger.LogLine( $"[POST] Server confirmed command for game {postResponse.GameId}"); // Command was successfully processed. Remove from pending. - RemovePendingCommandsForGame(postResponse.GameId); + RemovePendingCommandsForGame( + postResponse.GameId, + rememberAcknowledgedEagleToken: true); } // UNKNOWN is benign (old servers that don't set status) diff --git a/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Editor/PersistentClientConnectionLifecycleTests.cs b/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Editor/PersistentClientConnectionLifecycleTests.cs index a57c975956..4fb4739379 100644 --- a/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Editor/PersistentClientConnectionLifecycleTests.cs +++ b/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Editor/PersistentClientConnectionLifecycleTests.cs @@ -1,8 +1,10 @@ +using System.Collections.Generic; using System.Reflection; using System.Threading; using System.Threading.Tasks; using eagle; using Grpc.Core; +using Net.Eagle0.Eagle.Api; using NUnit.Framework; using Timer = System.Timers.Timer; @@ -61,6 +63,33 @@ namespace eagle0.Tests { connection.Dispose(); } + [Test] + public void SuccessfulPostSuppressesItsStaleCommandTokenUntilServerAdvances() { + const long gameId = 7126; + const long postedToken = 799; + var connection = + new PersistentClientConnection(null, new Metadata(), CancellationToken.None); + var pending = new List { new() { + GameId = gameId, + Command = + new UniversalCommand { + EagleCommand = new EagleCommand { EagleToken = postedToken } + } + } }; + SetPrivateField(connection, "_pendingCommands", pending); + + InvokePrivate(connection, "RemovePendingCommandsForGame", gameId, true); + + Assert.IsEmpty( + GetPrivateField>(connection, "_pendingCommands")); + Assert.IsTrue(connection.HasPendingEagleCommandWithToken(gameId, postedToken)); + Assert.IsTrue(connection.HasPendingEagleCommandWithToken(gameId, postedToken)); + Assert.IsFalse(connection.HasPendingEagleCommandWithToken(gameId, postedToken + 1)); + Assert.IsFalse(connection.HasPendingEagleCommandWithToken(gameId, postedToken)); + + connection.Dispose(); + } + private static T GetPrivateField(object target, string fieldName) { var field = target.GetType().GetField( fieldName,