Skip to content

Commit a1ec19a

Browse files
HandyS11claude
andcommitted
fix(connections): stop a transient request timeout from killing the connection loop
A per-request timeout during a connected window surfaces as an OperationCanceledException even when the connection token is NOT cancelled. GetRigPositionsAsync rethrew it unconditionally and RunAsync treats any OCE as a shutdown signal, so a transient slow map endpoint permanently terminated the per-server connection loop with no reconnect (bot connected, dropped ~20s later, then went silent). Confirmed via a live repro trace. Guard the timeout catches with `when (ct.IsCancellationRequested)` in GetRigPositionsAsync, PollMarkersAsync and PollReachabilityAsync so a request timeout degrades that one window instead of tearing down the connection, and add the missing `.WaitAsync` timeout guard to GetClanInfoAsync (the one connect-time probe that could block indefinitely on a dead socket). Regression test: Connect_MonumentsTimeout_DoesNotKillLoop_AndStillReconnects. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 00039cc commit a1ec19a

4 files changed

Lines changed: 65 additions & 8 deletions

File tree

src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,10 @@ public async Task<ClanProbeResult> GetClanInfoAsync(TimeSpan timeout, Cancellati
380380
{
381381
// CONFIRMED (2.0.0-beta.4): GetClanInfoAsync returns Task<Response<ClanInfo>>, exposing
382382
// IsSuccess, Data, and Error?.Code as the response accessors.
383-
var response = await _rustPlus.GetClanInfoAsync(timeoutCts.Token).ConfigureAwait(false);
383+
// .WaitAsync guards the timeout even if the beta call doesn't internally honor the token
384+
// (matching the other probes): on a dead socket this must never block the connect path.
385+
var response = await _rustPlus.GetClanInfoAsync(timeoutCts.Token)
386+
.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
384387
return ClanMapping.FromResponse(response.IsSuccess, response.Error?.Code, response.Data);
385388
}
386389
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)

src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -703,14 +703,16 @@ await eventBus.PublishAsync(
703703
.ConfigureAwait(false);
704704
}
705705
}
706-
catch (OperationCanceledException)
706+
catch (OperationCanceledException) when (ct.IsCancellationRequested)
707707
{
708708
return; // stopping
709709
}
710-
#pragma warning disable CA1031 // Broad catch: a failed poll is logged and skipped; the previous snapshot is retained.
710+
#pragma warning disable CA1031 // Broad catch: a failed poll (incl. a per-request timeout) is logged and skipped; the previous snapshot is retained.
711711
catch (Exception ex)
712712
#pragma warning restore CA1031
713713
{
714+
// A per-request timeout (OperationCanceledException with ct NOT cancelled) must NOT end the
715+
// poll loop — that would silently stop marker/rig/AFK detection for the rest of the connection.
714716
LogMarkerPollFailed(logger, ex, key.Server);
715717
}
716718

@@ -767,14 +769,16 @@ private async Task PollReachabilityAsync(
767769
{
768770
current = await ReadAllReachabilityAsync(key, connection, ct).ConfigureAwait(false);
769771
}
770-
catch (OperationCanceledException)
772+
catch (OperationCanceledException) when (ct.IsCancellationRequested)
771773
{
772774
throw;
773775
}
774-
#pragma warning disable CA1031 // Broad catch: a failed reachability sweep is logged and retried next cycle.
776+
#pragma warning disable CA1031 // Broad catch: a failed reachability sweep (incl. a per-request timeout) is logged and retried next cycle.
775777
catch (Exception ex)
776778
#pragma warning restore CA1031
777779
{
780+
// A per-request timeout (OperationCanceledException with ct NOT cancelled) must be retried next
781+
// cycle, not rethrown — rethrowing would tear the sweep down for the rest of the connection.
778782
LogReachabilityPollFailed(logger, ex, key.Server);
779783
continue;
780784
}
@@ -942,14 +946,17 @@ private async Task<IReadOnlyList<RigPosition>> GetRigPositionsAsync(
942946

943947
return rigs;
944948
}
945-
catch (OperationCanceledException)
949+
catch (OperationCanceledException) when (ct.IsCancellationRequested)
946950
{
951+
// Real shutdown/reconnect of THIS connection: propagate so the loop tears down.
947952
throw;
948953
}
949-
#pragma warning disable CA1031 // Broad catch: a monuments-fetch failure just disables rig detection this window.
954+
#pragma warning disable CA1031 // Broad catch: a monuments-fetch failure (incl. a per-request timeout) just disables rig detection this window.
950955
catch (Exception ex)
951956
#pragma warning restore CA1031
952957
{
958+
// A per-request timeout surfaces as OperationCanceledException with ct NOT cancelled; it must
959+
// degrade rig detection for this window, never terminate the connection loop.
953960
LogMonumentsFetchFailed(logger, ex, serverId); // rig detection degrades gracefully for this window
954961
return [];
955962
}

tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,33 @@ public async Task Heartbeat_Unreachable_ReconnectsAndRecovers()
231231
Assert.True(source.CreateCount >= 2);
232232
}
233233

234+
/// <summary>
235+
/// A per-request timeout during connect-time setup (here the oil-rig monuments fetch) surfaces as an
236+
/// <see cref="OperationCanceledException"/> even though the connection token is not cancelled. It must
237+
/// degrade rig detection for this window, NOT terminate the whole connection loop — otherwise a
238+
/// transient slow map endpoint permanently kills the connection with no reconnect (the real-world bug).
239+
/// </summary>
240+
[Fact]
241+
public async Task Connect_MonumentsTimeout_DoesNotKillLoop_AndStillReconnects()
242+
{
243+
var source = new FakeRustSocketSource();
244+
source.EnqueueConnect(SocketConnectOutcome.Connected);
245+
source.TimeoutOnMonumentsOnce(); // connect-time rig fetch times out on the FIRST connection only
246+
source.EnqueueHeartbeat(HeartbeatResult.Ok(2)); // first heartbeat -> Connected
247+
source.EnqueueHeartbeat(HeartbeatResult.Unreachable); // next heartbeat -> drop
248+
source.EnqueueConnect(SocketConnectOutcome.Connected); // reconnect
249+
source.EnqueueHeartbeat(HeartbeatResult.Ok(4));
250+
await using var h = CreateHarness(source);
251+
var (serverId, _, _) = await SeedAsync(h.Provider);
252+
253+
await h.Supervisor.EnsureConnectionAsync(10UL, serverId);
254+
255+
var recovered = await WaitForStateAsync(
256+
h.Provider, serverId, s => s.Status == ConnectionStatus.Connected && s.PlayerCount == 4);
257+
Assert.NotNull(recovered);
258+
Assert.True(source.CreateCount >= 2);
259+
}
260+
234261
/// <summary>
235262
/// WasConnected must be computed from in-process state (the DB status survives restarts and
236263
/// would claim Connected at boot): statuses before the first Connected carry false; the drop

tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ internal sealed class FakeRustSocketSource : IRustSocketSource
2929
private HeartbeatResult _lastHeartbeat = HeartbeatResult.Ok(0);
3030
private ClanProbeResult? _pendingClanProbe;
3131
private IReadOnlyList<MonumentSnapshot> _pendingMonuments = [];
32+
private bool _pendingMonumentsTimeout;
3233

3334
/// <summary>Number of times <see cref="Create"/> has been called. Safe to read from any thread.</summary>
3435
public int CreateCount => Volatile.Read(ref _createCount);
@@ -60,6 +61,11 @@ public IRustServerConnection Create(string ip, int port, ulong steamId, string p
6061
connection.MonumentsResult = _pendingMonuments;
6162
_pendingMonuments = [];
6263

64+
// Transfer any pre-staged monuments timeout so the connect-time rig fetch throws for the NEXT
65+
// connection only (mimicking a real per-request timeout, which surfaces as OperationCanceledException).
66+
connection.MonumentsTimeout = _pendingMonumentsTimeout;
67+
_pendingMonumentsTimeout = false;
68+
6369
// Transfer any pre-staged storage contents so they are in place before the prime loop starts.
6470
foreach (var (entityId, contents) in _pendingStorageContents)
6571
{
@@ -119,6 +125,14 @@ public void EnqueueMarkers(IReadOnlyList<MapMarkerSnapshot> markers) =>
119125
/// <param name="monuments">The monument list to return from <see cref="IRustServerConnection.GetMonumentsAsync"/>.</param>
120126
public void SetMonuments(IReadOnlyList<MonumentSnapshot> monuments) => _pendingMonuments = monuments;
121127

128+
/// <summary>
129+
/// Makes the NEXT connection's connect-time <see cref="FakeConnection.GetMonumentsAsync"/> throw
130+
/// <see cref="OperationCanceledException"/>, simulating a per-request timeout (the outer connection
131+
/// token is NOT cancelled). Applies to the next connection only. Call before
132+
/// <see cref="EnsureConnectionAsync"/>.
133+
/// </summary>
134+
public void TimeoutOnMonumentsOnce() => _pendingMonumentsTimeout = true;
135+
122136
/// <summary>
123137
/// Pre-stages the probe result returned by <see cref="FakeConnection.GetClanInfoAsync"/> for the NEXT
124138
/// connection created by <see cref="Create"/>. Transferred to the new connection at creation time, before
@@ -244,6 +258,10 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke
244258
/// <summary>The monuments returned by <see cref="GetMonumentsAsync"/>. Defaults to empty.</summary>
245259
public IReadOnlyList<MonumentSnapshot> MonumentsResult { get; set; } = [];
246260

261+
/// <summary>When true, <see cref="GetMonumentsAsync"/> throws <see cref="OperationCanceledException"/>
262+
/// (a per-request timeout) instead of returning <see cref="MonumentsResult"/>.</summary>
263+
public bool MonumentsTimeout { get; set; }
264+
247265
/// <summary>The bytes returned by <see cref="GetMapImageAsync"/>. Defaults to null.</summary>
248266
public byte[]? MapImageResult { get; set; }
249267

@@ -393,7 +411,9 @@ public Task<IReadOnlyList<MapMarkerSnapshot>> GetMapMarkersAsync(TimeSpan timeou
393411

394412
public Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(TimeSpan timeout,
395413
CancellationToken cancellationToken = default) =>
396-
Task.FromResult(MonumentsResult);
414+
MonumentsTimeout
415+
? Task.FromException<IReadOnlyList<MonumentSnapshot>>(new OperationCanceledException())
416+
: Task.FromResult(MonumentsResult);
397417

398418
public Task<byte[]?> GetMapImageAsync(TimeSpan timeout, CancellationToken cancellationToken = default) =>
399419
Task.FromResult(MapImageResult);

0 commit comments

Comments
 (0)