Serialize TryPendingCommands to prevent BAD_TOKEN bursts (#6630)

Multiple MainQueue actions (one per ActionResultResponse and
ShardokActionResultResponse) each call TryPendingCommands as
async void lambdas. When one yields at an await, the MainQueue
Update loop immediately invokes the next action, which re-enters
TryPendingCommands. Because PostRequest synchronously re-adds
the command to _pendingCommands before yielding for WriteAsync,
each parallel invocation re-drains the same buffered command and
writes it to the gRPC stream again. This produces bursts of 10+
identical writes in a single frame, all of which the server
rejects with BAD_TOKEN.

Wrap TryPendingCommands in a non-blocking SemaphoreSlim acquire.
If another invocation is already running, skip and return — any
items added to _pendingCommands while it runs will be picked up
by the next ActionResultResponse handler that fires.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 21:34:12 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent e2221c4e45
commit a912347dbd
@@ -191,6 +191,16 @@ namespace eagle {
private List<PostCommandRequest> _pendingCommands = new();
// Serializes TryPendingCommands invocations. Multiple MainQueue actions (one per
// ActionResultResponse / ShardokActionResultResponse) each call TryPendingCommands,
// and they're enqueued as `async void` lambdas — when one yields at an await, the
// MainQueue's per-frame Update loop immediately invokes the next action, which
// re-enters TryPendingCommands. Without this guard, each invocation re-drains
// _pendingCommands (which PostRequest synchronously re-adds to before yielding),
// causing the same buffered command to be written to the wire many times in a
// single frame and producing bursts of BAD_TOKEN responses from the server.
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
@@ -542,6 +552,18 @@ namespace eagle {
}
private async Task<bool> TryPendingCommands() {
// Try-acquire-or-skip: if another invocation is already draining the queue,
// bail out immediately. The running invocation will process whatever is in
// _pendingCommands at the moment it copied the list; anything that arrives
// later (from a fresh PostRequest, or from a server response triggering
// RemovePendingCommandsForGame + RefreshGameSubscription) will be picked up
// by the next ActionResultResponse handler that calls TryPendingCommands.
if (!await _tryPendingCommandsSemaphore.WaitAsync(0)) {
_remoteEagleClientLogger.LogLine(
"TryPendingCommands skipped - another invocation is in progress");
return true;
}
Queue<PostCommandRequest> pendingCommands = new();
try {
lock (this) {
@@ -662,7 +684,7 @@ namespace eagle {
}
return false;
}
} finally { _tryPendingCommandsSemaphore.Release(); }
}
public void Disconnect() {
@@ -722,6 +744,7 @@ namespace eagle {
}
tokenSourceToDispose?.Dispose();
_tryPendingCommandsSemaphore.Dispose();
}
public void SetLobbySubscriber(ILobbySubscriber subscriber) {