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; }
/// <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
/// 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>
public Func<long, bool> 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(
@@ -10,9 +10,8 @@ namespace eagle {
public Int64? CurrentShardokToken(string shardokGameId);
/// <summary>
/// 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.
/// </summary>
public Func<long, bool> HasPendingCommandWithToken { get; set; }
@@ -216,6 +216,7 @@ namespace eagle {
private CancellationToken _currentThreadToken;
private List<PostCommandRequest> _pendingCommands = new();
private readonly Dictionary<EagleGameId, long> _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);
/// <summary>
/// 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.
/// </summary>
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 {
}
/// <summary>
/// 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.
/// </summary>
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)
@@ -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<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) {
var field = target.GetType().GetField(
fieldName,