Skip to content

Commit d1eeec1

Browse files
committed
fix(connections): address review — stable heartbeat fake, Ensure race gate, no token leak, swap value validation
1 parent ec435ee commit d1eeec1

7 files changed

Lines changed: 102 additions & 42 deletions

File tree

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,29 @@ public IRustServerConnection Create(string ip, int port, ulong steamId, string p
1313
{
1414
if (!int.TryParse(playerToken, CultureInfo.InvariantCulture, out var token))
1515
{
16-
throw new ArgumentException($"playerToken is not a valid numeric token: '{playerToken}'.",
17-
nameof(playerToken));
16+
LogInvalidToken(logger);
17+
return new RejectedConnection();
1818
}
1919

2020
return new RustPlusServerConnection(ip, port, steamId, token, logger);
2121
}
2222

23+
[LoggerMessage(Level = LogLevel.Warning,
24+
Message = "Player token is not a valid numeric token; treating the credential as rejected.")]
25+
private static partial void LogInvalidToken(ILogger logger);
26+
27+
/// <summary>Returned when the player token is unusable; reports the credential as rejected and does nothing else.</summary>
28+
private sealed class RejectedConnection : IRustServerConnection
29+
{
30+
public Task<SocketConnectOutcome> ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken) =>
31+
Task.FromResult(SocketConnectOutcome.AuthRejected);
32+
33+
public Task<HeartbeatResult> GetInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) =>
34+
Task.FromResult(HeartbeatResult.AuthRejected);
35+
36+
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
37+
}
38+
2339
/// <summary>
2440
/// One live Rust+ connection backed by <see cref="RustPlus"/>.
2541
/// <para>API notes (2.0.0-beta.1): constructor takes <see cref="RustPlusConnection"/> instead of

src/RustPlusBot.Features.Connections/Modules/ConnectionComponentModule.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public async Task SwapAsync(string serverIdRaw, string[] selectedValues)
2828
}
2929

3030
if (!Guid.TryParse(serverIdRaw, out var serverId)
31-
|| selectedValues.Length == 0
31+
|| selectedValues.Length != 1
3232
|| !Guid.TryParse(selectedValues[0], out var credentialId))
3333
{
3434
await RespondAsync("That selection wasn't valid.", ephemeral: true).ConfigureAwait(false);

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

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ internal sealed partial class ConnectionSupervisor(
3232
ILogger<ConnectionSupervisor> logger) : IConnectionSupervisor, IAsyncDisposable
3333
{
3434
private readonly ConcurrentDictionary<(ulong Guild, Guid Server), Handle> _connections = new();
35+
private readonly SemaphoreSlim _gate = new(1, 1);
3536
private readonly ConnectionOptions _options = options.Value;
3637
private readonly CancellationTokenSource _shutdown = new();
3738

@@ -40,6 +41,7 @@ public async ValueTask DisposeAsync()
4041
{
4142
await StopAllAsync().ConfigureAwait(false);
4243
_shutdown.Dispose();
44+
_gate.Dispose();
4345
}
4446

4547
/// <inheritdoc />
@@ -63,26 +65,53 @@ public async Task StartAllAsync(CancellationToken cancellationToken = default)
6365
public async Task EnsureConnectionAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default)
6466
{
6567
var key = (guildId, serverId);
66-
await StopConnectionAsync(key).ConfigureAwait(false);
67-
if (_shutdown.IsCancellationRequested)
68+
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
69+
try
6870
{
69-
return;
70-
}
71+
await StopConnectionAsync(key).ConfigureAwait(false);
72+
if (_shutdown.IsCancellationRequested)
73+
{
74+
return;
75+
}
7176

72-
var cts = CancellationTokenSource.CreateLinkedTokenSource(_shutdown.Token);
73-
_connections[key] = new Handle(cts, Task.Run(() => RunAsync(key, cts.Token), CancellationToken.None));
77+
var cts = CancellationTokenSource.CreateLinkedTokenSource(_shutdown.Token);
78+
_connections[key] = new Handle(cts, Task.Run(() => RunAsync(key, cts.Token), CancellationToken.None));
79+
}
80+
finally
81+
{
82+
_gate.Release();
83+
}
7484
}
7585

7686
/// <inheritdoc />
77-
public Task StopAsync(ulong guildId, Guid serverId) => StopConnectionAsync((guildId, serverId));
87+
public async Task StopAsync(ulong guildId, Guid serverId)
88+
{
89+
await _gate.WaitAsync().ConfigureAwait(false);
90+
try
91+
{
92+
await StopConnectionAsync((guildId, serverId)).ConfigureAwait(false);
93+
}
94+
finally
95+
{
96+
_gate.Release();
97+
}
98+
}
7899

79100
/// <inheritdoc />
80101
public async Task StopAllAsync()
81102
{
82103
await _shutdown.CancelAsync().ConfigureAwait(false);
83-
foreach (var key in _connections.Keys.ToList())
104+
await _gate.WaitAsync().ConfigureAwait(false);
105+
try
84106
{
85-
await StopConnectionAsync(key).ConfigureAwait(false);
107+
foreach (var key in _connections.Keys.ToList())
108+
{
109+
await StopConnectionAsync(key).ConfigureAwait(false);
110+
}
111+
}
112+
finally
113+
{
114+
_gate.Release();
86115
}
87116
}
88117

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

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,21 @@ private static Harness CreateHarness(FakeRustSocketSource source)
3737
services.AddSingleton(dm);
3838
services.AddSingleton<IEventBus, InMemoryEventBus>();
3939

40-
var (seed, connection) = TestDb.Create();
41-
seed.Dispose();
42-
services.AddSingleton(connection);
43-
services.AddScoped(sp => new BotDbContext(
44-
new DbContextOptionsBuilder<BotDbContext>().UseSqlite(sp.GetRequiredService<SqliteConnection>()).Options));
40+
// Each scope opens its OWN connection to a shared-cache in-memory database, so the background
41+
// supervisor loop and the test's polling never run concurrent commands on a single SqliteConnection
42+
// (which throws "active statements" misuse errors). One kept-open connection keeps the in-memory DB alive.
43+
var connectionString = $"DataSource=connsup-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
44+
var keepAlive = new SqliteConnection(connectionString);
45+
keepAlive.Open();
46+
using (var seed = new BotDbContext(
47+
new DbContextOptionsBuilder<BotDbContext>().UseSqlite(connectionString).Options))
48+
{
49+
seed.Database.Migrate();
50+
}
51+
52+
services.AddSingleton(keepAlive);
53+
services.AddScoped(_ => new BotDbContext(
54+
new DbContextOptionsBuilder<BotDbContext>().UseSqlite(connectionString).Options));
4555
services.AddScoped<IConnectionStore, ConnectionStore>();
4656
services
4757
.AddScoped<RustPlusBot.Persistence.Servers.IServerService, RustPlusBot.Persistence.Servers.ServerService>();
@@ -103,7 +113,7 @@ private static Harness CreateHarness(FakeRustSocketSource source)
103113
Guid serverId,
104114
Func<ConnectionState, bool> predicate)
105115
{
106-
var deadline = DateTimeOffset.UtcNow.AddSeconds(20);
116+
var deadline = DateTimeOffset.UtcNow.AddSeconds(30);
107117
while (DateTimeOffset.UtcNow < deadline)
108118
{
109119
using var scope = provider.CreateScope();
@@ -224,7 +234,7 @@ public async Task StartAll_StartsAConnectionPerConnectableServer()
224234

225235
await h.Supervisor.StartAllAsync();
226236

227-
var deadline = DateTimeOffset.UtcNow.AddSeconds(20);
237+
var deadline = DateTimeOffset.UtcNow.AddSeconds(30);
228238
while (source.CreateCount < 1 && DateTimeOffset.UtcNow < deadline)
229239
{
230240
await Task.Delay(15);

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,17 @@ namespace RustPlusBot.Features.Connections.Tests.Fakes;
1212
/// source creates, consumed in enqueue order. This is intentional: the supervisor tests drive one
1313
/// server at a time and enqueue a deterministic sequence that must flow across reconnections of that
1414
/// server. Tests that need concurrent multi-server interleaving would require per-connection queues.
15+
/// Once the heartbeat queue is exhausted, the fake HOLDS the last dequeued heartbeat (mimicking a
16+
/// healthy server that keeps reporting the same state), rather than falling back to Ok(0).
1517
/// </remarks>
1618
internal sealed class FakeRustSocketSource : IRustSocketSource
1719
{
1820
private readonly ConcurrentQueue<SocketConnectOutcome> _connectOutcomes = new();
1921
private readonly ConcurrentQueue<HeartbeatResult> _heartbeats = new();
20-
2122
private int _createCount;
2223

24+
private HeartbeatResult _lastHeartbeat = HeartbeatResult.Ok(0);
25+
2326
/// <summary>Number of times <see cref="Create"/> has been called. Safe to read from any thread.</summary>
2427
public int CreateCount => Volatile.Read(ref _createCount);
2528

@@ -35,21 +38,31 @@ public IRustServerConnection Create(string ip, int port, ulong steamId, string p
3538
LastIp = ip;
3639
LastSteamId = steamId;
3740
var outcome = _connectOutcomes.TryDequeue(out var next) ? next : SocketConnectOutcome.Connected;
38-
return new FakeConnection(outcome, _heartbeats);
41+
return new FakeConnection(outcome, this);
3942
}
4043

4144
public void EnqueueConnect(SocketConnectOutcome outcome) => _connectOutcomes.Enqueue(outcome);
4245

4346
public void EnqueueHeartbeat(HeartbeatResult result) => _heartbeats.Enqueue(result);
4447

45-
private sealed class FakeConnection(SocketConnectOutcome outcome, ConcurrentQueue<HeartbeatResult> heartbeats)
48+
internal HeartbeatResult NextHeartbeat()
49+
{
50+
if (_heartbeats.TryDequeue(out var next))
51+
{
52+
_lastHeartbeat = next;
53+
}
54+
55+
return _lastHeartbeat;
56+
}
57+
58+
private sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocketSource source)
4659
: IRustServerConnection
4760
{
4861
public Task<SocketConnectOutcome> ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken) =>
4962
Task.FromResult(outcome);
5063

5164
public Task<HeartbeatResult> GetInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) =>
52-
Task.FromResult(heartbeats.TryDequeue(out var next) ? next : HeartbeatResult.Ok(0));
65+
Task.FromResult(source.NextHeartbeat());
5366

5467
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
5568
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,15 @@ public async Task Create_ReturnsADisposableConnection()
1515

1616
Assert.NotNull(connection);
1717
}
18+
19+
[Fact]
20+
public async Task Create_NonNumericToken_YieldsAuthRejectedConnection()
21+
{
22+
var source = new RustPlusSocketSource(NullLogger<RustPlusSocketSource>.Instance);
23+
24+
var connection = source.Create("127.0.0.1", 28015, 100UL, "not-a-number");
25+
await using var _ = connection;
26+
27+
Assert.Equal(SocketConnectOutcome.AuthRejected, await connection.ConnectAsync(TimeSpan.Zero, default));
28+
}
1829
}

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

Lines changed: 0 additions & 19 deletions
This file was deleted.

0 commit comments

Comments
 (0)