Suppress stale command sets after successful posts

This commit is contained in:
2026-07-18 19:19:40 -07:00
parent f7d79639b5
commit cb0c08396d
4 changed files with 69 additions and 16 deletions
@@ -88,10 +88,10 @@ namespace eagle {
public Action<Notification> NoteRecipient { get; set; } public Action<Notification> NoteRecipient { get; set; }
/// <summary> /// <summary>
/// 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 /// Set by PersistentClientConnection. When this returns true, HandleAvailableCommands
/// will set the token (for TryPendingCommands matching) but not load the commands, /// 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.
/// </summary> /// </summary>
public Func<long, bool> HasPendingCommandWithToken { get; set; } public Func<long, bool> HasPendingCommandWithToken { get; set; }
@@ -863,10 +863,9 @@ namespace eagle {
_currentModel.CommandToken = availableCommands.Token; _currentModel.CommandToken = availableCommands.Token;
// Check if there's a pending command with this token waiting to be posted. // Check if a command with this token is pending or was already acknowledged.
// If so, don't load the commands - they're stale (we already acted on them). // If so, don't load the commands: this response is stale because we already acted
// The pending command will be posted by TryPendingCommands, and we'll get // on this token. A fresh command set will arrive after the post is processed.
// fresh commands after it's processed.
if (HasPendingCommandWithToken != null && if (HasPendingCommandWithToken != null &&
HasPendingCommandWithToken(availableCommands.Token)) { HasPendingCommandWithToken(availableCommands.Token)) {
_connectionLogger.LogLine( _connectionLogger.LogLine(
@@ -10,9 +10,8 @@ namespace eagle {
public Int64? CurrentShardokToken(string shardokGameId); public Int64? CurrentShardokToken(string shardokGameId);
/// <summary> /// <summary>
/// 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. Used to skip loading stale commands /// Set by PersistentClientConnection and used to suppress stale command-set responses.
/// when a pending command is about to be posted.
/// </summary> /// </summary>
public Func<long, bool> HasPendingCommandWithToken { get; set; } public Func<long, bool> HasPendingCommandWithToken { get; set; }
@@ -216,6 +216,7 @@ namespace eagle {
private CancellationToken _currentThreadToken; private CancellationToken _currentThreadToken;
private List<PostCommandRequest> _pendingCommands = new(); private List<PostCommandRequest> _pendingCommands = new();
private readonly Dictionary<EagleGameId, long> _acknowledgedEagleCommandTokens = new();
// Serializes TryPendingCommands invocations. Multiple MainQueue actions (one per // Serializes TryPendingCommands invocations. Multiple MainQueue actions (one per
// ActionResultResponse / ShardokActionResultResponse) each call TryPendingCommands, // ActionResultResponse / ShardokActionResultResponse) each call TryPendingCommands,
@@ -228,12 +229,22 @@ namespace eagle {
private readonly SemaphoreSlim _tryPendingCommandsSemaphore = new(1, 1); private readonly SemaphoreSlim _tryPendingCommandsSemaphore = new(1, 1);
/// <summary> /// <summary>
/// Checks if there's a pending Eagle command with the specified token for the given game. /// Checks if an Eagle command with the specified token is pending or has already been
/// Used by GameModelUpdater to skip loading stale commands when a pending command /// acknowledged by the server. GameModelUpdater uses this to suppress a stale command-set
/// is about to be posted. /// response that can race with the result of a successful post.
/// </summary> /// </summary>
public bool HasPendingEagleCommandWithToken(long gameId, long token) { public bool HasPendingEagleCommandWithToken(long gameId, long token) {
lock (this) { 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) { foreach (var cmd in _pendingCommands) {
if (cmd.GameId == gameId && if (cmd.GameId == gameId &&
cmd.Command.SealedValueCase == cmd.Command.SealedValueCase ==
@@ -394,12 +405,24 @@ namespace eagle {
} }
/// <summary> /// <summary>
/// Remove all pending commands for a specific game. /// Remove all pending commands for a specific game. Successful Eagle command tokens are
/// Called when the server confirms a command was processed (SUCCESS or BAD_TOKEN). /// retained as a short-lived acknowledgement fence so a stale stream update cannot
/// restore commands for a command the server has already accepted.
/// </summary> /// </summary>
private void RemovePendingCommandsForGame(long gameId) { private void RemovePendingCommandsForGame(
long gameId,
bool rememberAcknowledgedEagleToken = false) {
lock (this) { lock (this) {
var toRemove = _pendingCommands.Where(cmd => cmd.GameId == gameId).ToList(); 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); } foreach (var cmd in toRemove) { _pendingCommands.Remove(cmd); }
if (toRemove.Count > 0) { if (toRemove.Count > 0) {
_remoteEagleClientLogger.LogLine( _remoteEagleClientLogger.LogLine(
@@ -811,6 +834,7 @@ namespace eagle {
// Clear pending commands // Clear pending commands
_pendingCommands.Clear(); _pendingCommands.Clear();
_acknowledgedEagleCommandTokens.Clear();
OnChannelDead = null; OnChannelDead = null;
OnCommandError = null; OnCommandError = null;
@@ -1446,7 +1470,9 @@ namespace eagle {
_remoteEagleClientLogger.LogLine( _remoteEagleClientLogger.LogLine(
$"[POST] Server confirmed command for game {postResponse.GameId}"); $"[POST] Server confirmed command for game {postResponse.GameId}");
// Command was successfully processed. Remove from pending. // 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) // UNKNOWN is benign (old servers that don't set status)
@@ -1,8 +1,10 @@
using System.Collections.Generic;
using System.Reflection; using System.Reflection;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using eagle; using eagle;
using Grpc.Core; using Grpc.Core;
using Net.Eagle0.Eagle.Api;
using NUnit.Framework; using NUnit.Framework;
using Timer = System.Timers.Timer; using Timer = System.Timers.Timer;
@@ -61,6 +63,33 @@ namespace eagle0.Tests {
connection.Dispose(); 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<PostCommandRequest> { new() {
GameId = gameId,
Command =
new UniversalCommand {
EagleCommand = new EagleCommand { EagleToken = postedToken }
}
} };
SetPrivateField(connection, "_pendingCommands", pending);
InvokePrivate(connection, "RemovePendingCommandsForGame", gameId, true);
Assert.IsEmpty(
GetPrivateField<List<PostCommandRequest>>(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<T>(object target, string fieldName) { private static T GetPrivateField<T>(object target, string fieldName) {
var field = target.GetType().GetField( var field = target.GetType().GetField(
fieldName, fieldName,