Skip to content

Commit 4d8a5e1

Browse files
HandyS11claude
andcommitted
feat(connections): detect server-initiated socket drops with a liveness watchdog
The Rust+ library raises no event when the server closes the socket, so a silent drop previously went unnoticed until the next heartbeat (up to HeartbeatInterval, default 60s) — a long window of a dead-but-"connected" socket. Add IsConnected to IRustServerConnection (backed by RustPlusSocket.IsConnected) and a WatchLivenessAsync watchdog that races the heartbeat loop and returns Unreachable as soon as the socket is no longer open, bounded by the new ConnectionOptions.LivenessPollInterval (default 5s). Test: ServerClosesSocket_LivenessWatchdog_DrivesReconnect (heartbeat keeps answering Ok, so only the watchdog can detect the drop). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b46d063 commit 4d8a5e1

6 files changed

Lines changed: 104 additions & 3 deletions

File tree

src/RustPlusBot.Features.Connections/ConnectionOptions.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,11 @@ public sealed class ConnectionOptions
4444

4545
/// <summary>How often to poll managed devices for reachability changes while connected. Default 5m.</summary>
4646
public TimeSpan ReachabilityPollInterval { get; set; } = TimeSpan.FromMinutes(5);
47+
48+
/// <summary>
49+
/// How often the liveness watchdog checks whether the socket is still open while connected. The Rust+
50+
/// library raises no event when the server closes the socket, so this bounds how long a silent drop goes
51+
/// unnoticed (independently of <see cref="HeartbeatInterval"/>). Default 5s.
52+
/// </summary>
53+
public TimeSpan LivenessPollInterval { get; set; } = TimeSpan.FromSeconds(5);
4754
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ namespace RustPlusBot.Features.Connections.Listening;
55
/// <summary>One live Rust+ socket to a single server, driven by one player credential.</summary>
66
internal interface IRustServerConnection : IAsyncDisposable
77
{
8+
/// <summary>
9+
/// Whether the underlying socket is currently open. Cheap and synchronous: the Rust+ library raises no
10+
/// event when the <em>server</em> closes the socket, so a liveness watchdog polls this to detect a drop
11+
/// promptly instead of waiting for the next (up to a minute apart) heartbeat.
12+
/// </summary>
13+
bool IsConnected { get; }
14+
815
/// <summary>Connects within <paramref name="timeout"/>.</summary>
916
/// <param name="timeout">How long to wait for the connection.</param>
1017
/// <param name="cancellationToken">A cancellation token.</param>

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ public IRustServerConnection Create(string ip, int port, ulong steamId, string p
3434
/// <summary>Returned when the player token is unusable; reports the credential as rejected and does nothing else.</summary>
3535
private sealed class RejectedConnection : IRustServerConnection
3636
{
37+
public bool IsConnected => false;
38+
3739
public Task<SocketConnectOutcome> ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken) =>
3840
Task.FromResult(SocketConnectOutcome.AuthRejected);
3941

@@ -152,6 +154,11 @@ private sealed partial class RustPlusServerConnection : IRustServerConnection
152154
private readonly ILogger _logger;
153155
private readonly RustPlus _rustPlus;
154156

157+
/// <inheritdoc />
158+
// CONFIRMED (2.0.0-beta.5): RustPlusSocket.IsConnected == (_webSocket?.State == WebSocketState.Open),
159+
// so it flips to false as soon as the server closes the socket (the library raises no event for that).
160+
public bool IsConnected => _rustPlus.IsConnected;
161+
155162
public RustPlusServerConnection(string ip,
156163
int port,
157164
ulong steamId,

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

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -611,17 +611,25 @@ void OnClanChanged(object? sender, ClanProbeResult probe)
611611
CancellationToken.None);
612612
var reachabilityPoll = Task.Run(() => PollReachabilityAsync(key, connection, pollCts.Token),
613613
CancellationToken.None);
614+
// Race the heartbeat against a liveness watchdog: the Rust+ library raises no event when the SERVER
615+
// closes the socket, so without the watchdog a silent drop goes unnoticed until the next heartbeat
616+
// (up to a minute). Whichever signals a reason first wins; the finally cancels and joins the rest.
617+
var heartbeat = RunHeartbeatLoopAsync(key, connection, credentialId, pollCts.Token);
618+
var liveness = WatchLivenessAsync(key, connection, pollCts.Token);
614619
try
615620
{
616-
return await RunHeartbeatLoopAsync(key, connection, credentialId, ct).ConfigureAwait(false);
621+
#pragma warning disable VSTHRD003 // Suppress: heartbeat and liveness are owned by this connected window and joined below.
622+
var winner = await Task.WhenAny(heartbeat, liveness).ConfigureAwait(false);
623+
return await winner.ConfigureAwait(false);
624+
#pragma warning restore VSTHRD003
617625
}
618626
finally
619627
{
620628
await pollCts.CancelAsync().ConfigureAwait(false);
621629
try
622630
{
623-
#pragma warning disable VSTHRD003 // Suppress: markerPoll and reachabilityPoll are owned by this connected window and explicitly joined on exit.
624-
await Task.WhenAll(markerPoll, reachabilityPoll).ConfigureAwait(false);
631+
#pragma warning disable VSTHRD003 // Suppress: all four tasks are owned by this connected window and explicitly joined on exit.
632+
await Task.WhenAll(markerPoll, reachabilityPoll, heartbeat, liveness).ConfigureAwait(false);
625633
#pragma warning restore VSTHRD003
626634
}
627635
catch (OperationCanceledException)
@@ -638,6 +646,34 @@ void OnClanChanged(object? sender, ClanProbeResult probe)
638646
}
639647
}
640648

649+
/// <summary>
650+
/// Polls <see cref="IRustServerConnection.IsConnected"/> while connected and returns
651+
/// <see cref="ReconnectReason.Unreachable"/> as soon as the socket is no longer open. This is the only
652+
/// prompt signal for a server-initiated close, which the Rust+ library does not surface as an event.
653+
/// </summary>
654+
/// <param name="key">The (guild, server) routing key.</param>
655+
/// <param name="connection">The live connection whose liveness is watched.</param>
656+
/// <param name="ct">Cancels when the connected window ends.</param>
657+
/// <returns><see cref="ReconnectReason.Unreachable"/> on a detected drop, else
658+
/// <see cref="ReconnectReason.Stopped"/> when cancelled.</returns>
659+
private async Task<ReconnectReason> WatchLivenessAsync(
660+
(ulong Guild, Guid Server) key,
661+
IRustServerConnection connection,
662+
CancellationToken ct)
663+
{
664+
while (!ct.IsCancellationRequested)
665+
{
666+
await Task.Delay(_options.LivenessPollInterval, ct).ConfigureAwait(false);
667+
if (!connection.IsConnected)
668+
{
669+
LogSocketDropped(logger, key.Server);
670+
return ReconnectReason.Unreachable;
671+
}
672+
}
673+
674+
return ReconnectReason.Stopped;
675+
}
676+
641677
private async Task<ReconnectReason> RunHeartbeatLoopAsync(
642678
(ulong Guild, Guid Server) key,
643679
IRustServerConnection connection,
@@ -1442,6 +1478,10 @@ await eventBus.PublishAsync(
14421478
[LoggerMessage(Level = LogLevel.Warning, Message = "Reachability poll for server {ServerId} failed.")]
14431479
private static partial void LogReachabilityPollFailed(ILogger logger, Exception exception, Guid serverId);
14441480

1481+
[LoggerMessage(Level = LogLevel.Information,
1482+
Message = "Socket for server {ServerId} closed by the server; reconnecting.")]
1483+
private static partial void LogSocketDropped(ILogger logger, Guid serverId);
1484+
14451485
[LoggerMessage(Level = LogLevel.Warning,
14461486
Message =
14471487
"Fetching monuments for oil-rig detection on server {ServerId} failed; rig detection disabled for this connection.")]

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ private static Harness CreateHarness(FakeRustSocketSource source)
6767
HeartbeatTimeout = TimeSpan.FromMilliseconds(200),
6868
MarkerPollInterval = TimeSpan.FromMilliseconds(20),
6969
MarkerPollFastInterval = TimeSpan.FromMilliseconds(20),
70+
LivenessPollInterval = TimeSpan.FromMilliseconds(20),
7071
}));
7172
services.AddSingleton<ConnectionSecurity>();
7273
services.AddSingleton<ConnectionSupervisor>();
@@ -258,6 +259,41 @@ public async Task Connect_MonumentsTimeout_DoesNotKillLoop_AndStillReconnects()
258259
Assert.True(source.CreateCount >= 2);
259260
}
260261

262+
/// <summary>
263+
/// The Rust+ library raises no event when the SERVER closes the socket; only
264+
/// <see cref="IRustServerConnection.IsConnected"/> flips. The liveness watchdog must notice that and
265+
/// drive a reconnect promptly, without waiting for the (up to a minute apart) heartbeat — here the fake's
266+
/// heartbeat keeps reporting Ok, so only the watchdog can detect the drop.
267+
/// </summary>
268+
[Fact]
269+
public async Task ServerClosesSocket_LivenessWatchdog_DrivesReconnect()
270+
{
271+
var source = new FakeRustSocketSource();
272+
source.EnqueueConnect(SocketConnectOutcome.Connected);
273+
source.EnqueueHeartbeat(HeartbeatResult.Ok(2)); // first heartbeat -> Connected; held thereafter
274+
source.EnqueueConnect(SocketConnectOutcome.Connected); // reconnect after the watchdog fires
275+
await using var h = CreateHarness(source);
276+
var (serverId, _, _) = await SeedAsync(h.Provider);
277+
278+
await h.Supervisor.EnsureConnectionAsync(10UL, serverId);
279+
var connected = await WaitForStateAsync(
280+
h.Provider, serverId, s => s.Status == ConnectionStatus.Connected && s.PlayerCount == 2);
281+
Assert.NotNull(connected);
282+
var live = source.LastConnection;
283+
Assert.NotNull(live);
284+
285+
// Simulate a server-initiated close: the socket is no longer open, but the heartbeat still answers Ok.
286+
live.IsConnected = false;
287+
288+
var deadline = DateTimeOffset.UtcNow.AddSeconds(30);
289+
while (source.CreateCount < 2 && DateTimeOffset.UtcNow < deadline)
290+
{
291+
await Task.Delay(15);
292+
}
293+
294+
Assert.True(source.CreateCount >= 2, "the liveness watchdog should have driven a reconnect");
295+
}
296+
261297
/// <summary>
262298
/// WasConnected must be computed from in-process state (the DB status survives restarts and
263299
/// would claim Connected at boot): statuses before the first Connected carry false; the drop

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke
195195
private IReadOnlyList<MapMarkerSnapshot> _lastMarkers = [];
196196
private bool _markerScriptStarted;
197197

198+
/// <summary>Liveness flag consulted by the supervisor's watchdog. Set false to simulate a
199+
/// server-initiated socket close (which the real library signals via <c>IsConnected</c>, not an event).</summary>
200+
public bool IsConnected { get; set; } = true;
201+
198202
/// <summary>Gets the messages sent via <see cref="SendTeamMessageAsync"/>.</summary>
199203
public List<string> SentMessages { get; } = [];
200204

0 commit comments

Comments
 (0)