Skip to content

Commit 8a4480a

Browse files
HandyS11claude
andcommitted
feat(devices): connect-prime publishes per-device reachability
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ee26646 commit 8a4480a

3 files changed

Lines changed: 228 additions & 7 deletions

File tree

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

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,9 +1020,16 @@ private async Task PublishDevicePrimeAsync(
10201020
.GetSmartDeviceInfoAsync(entityId, _options.HeartbeatTimeout, _shutdown.Token)
10211021
.ConfigureAwait(false);
10221022
await eventBus.PublishAsync(
1023-
new SmartDeviceTriggeredEvent(key.Guild, key.Server, entityId, reading.IsActive ?? false),
1023+
new DeviceReachabilityChangedEvent(key.Guild, key.Server, entityId, reading.Reachability),
10241024
_shutdown.Token)
10251025
.ConfigureAwait(false);
1026+
if (reading.Reachability == DeviceReachability.Reachable)
1027+
{
1028+
await eventBus.PublishAsync(
1029+
new SmartDeviceTriggeredEvent(key.Guild, key.Server, entityId, reading.IsActive ?? false),
1030+
_shutdown.Token)
1031+
.ConfigureAwait(false);
1032+
}
10261033
}
10271034
catch (OperationCanceledException)
10281035
{
@@ -1084,15 +1091,17 @@ private async Task PublishStoragePrimeAsync(
10841091
var reading = await connection
10851092
.GetStorageMonitorInfoAsync(entityId, _options.HeartbeatTimeout, _shutdown.Token)
10861093
.ConfigureAwait(false);
1087-
if (reading.Contents is not { } contents)
1088-
{
1089-
return; // unreachable read; the relay leaves the embed as-is (or unreachable via status events).
1090-
}
1091-
10921094
await eventBus.PublishAsync(
1093-
new StorageMonitorTriggeredEvent(key.Guild, key.Server, entityId, contents),
1095+
new DeviceReachabilityChangedEvent(key.Guild, key.Server, entityId, reading.Reachability),
10941096
_shutdown.Token)
10951097
.ConfigureAwait(false);
1098+
if (reading.Reachability == DeviceReachability.Reachable && reading.Contents is { } contents)
1099+
{
1100+
await eventBus.PublishAsync(
1101+
new StorageMonitorTriggeredEvent(key.Guild, key.Server, entityId, contents),
1102+
_shutdown.Token)
1103+
.ConfigureAwait(false);
1104+
}
10961105
}
10971106
catch (OperationCanceledException)
10981107
{

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ internal sealed class FakeRustSocketSource : IRustSocketSource
2222
private readonly ConcurrentQueue<HeartbeatResult> _heartbeats = new();
2323
private readonly ConcurrentQueue<IReadOnlyList<MapMarkerSnapshot>> _pendingMarkerScript = new();
2424
private readonly Dictionary<ulong, StorageContentsSnapshot?> _pendingStorageContents = [];
25+
private readonly Dictionary<ulong, DeviceReachability> _pendingDeviceReachabilityOverrides = [];
2526
private int _createCount;
2627

2728
private HeartbeatResult _lastHeartbeat = HeartbeatResult.Ok(0);
@@ -65,6 +66,14 @@ public IRustServerConnection Create(string ip, int port, ulong steamId, string p
6566

6667
_pendingStorageContents.Clear();
6768

69+
// Transfer any pre-staged device-reachability overrides so they are available before the prime loop starts.
70+
foreach (var (entityId, reachability) in _pendingDeviceReachabilityOverrides)
71+
{
72+
connection.DeviceReachabilityOverrides[entityId] = reachability;
73+
}
74+
75+
_pendingDeviceReachabilityOverrides.Clear();
76+
6877
LastConnection = connection;
6978
return connection;
7079
}
@@ -103,6 +112,19 @@ public void EnqueueMarkers(IReadOnlyList<MapMarkerSnapshot> markers) =>
103112
public void EnqueueStorageInfo(ulong entityId, StorageContentsSnapshot? contents) =>
104113
_pendingStorageContents[entityId] = contents;
105114

115+
/// <summary>
116+
/// Pre-stages a device-reachability override for a given entity, to be transferred to the NEXT connection
117+
/// created by <see cref="Create"/>. Eliminates the setup race when the prime loop reads reachability before
118+
/// the test can assign it on <see cref="FakeConnection.DeviceReachabilityOverrides"/>.
119+
/// Call this before <see cref="EnsureConnectionAsync"/>.
120+
/// </summary>
121+
/// <param name="entityId">The entity id to stage.</param>
122+
/// <param name="reachability">The reachability to return from
123+
/// <see cref="IRustServerConnection.GetSmartDeviceInfoAsync"/> or
124+
/// <see cref="IRustServerConnection.GetStorageMonitorInfoAsync"/> for this entity.</param>
125+
public void StageDeviceReachability(ulong entityId, DeviceReachability reachability) =>
126+
_pendingDeviceReachabilityOverrides[entityId] = reachability;
127+
106128
internal HeartbeatResult NextHeartbeat()
107129
{
108130
if (_heartbeats.TryDequeue(out var next))
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
using Microsoft.Data.Sqlite;
2+
using Microsoft.EntityFrameworkCore;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using Microsoft.Extensions.Options;
5+
using NSubstitute;
6+
using RustPlusBot.Abstractions.Connections;
7+
using RustPlusBot.Abstractions.Credentials;
8+
using RustPlusBot.Abstractions.Events;
9+
using RustPlusBot.Abstractions.Time;
10+
using RustPlusBot.Discord.Notifications;
11+
using RustPlusBot.Domain.Credentials;
12+
using RustPlusBot.Domain.Servers;
13+
using RustPlusBot.Features.Connections.Listening;
14+
using RustPlusBot.Features.Connections.Supervisor;
15+
using RustPlusBot.Features.Connections.Tests.Fakes;
16+
using RustPlusBot.Persistence;
17+
using RustPlusBot.Persistence.Alarms;
18+
using RustPlusBot.Persistence.Connections;
19+
using RustPlusBot.Persistence.Servers;
20+
using RustPlusBot.Persistence.StorageMonitors;
21+
using RustPlusBot.Persistence.Switches;
22+
23+
namespace RustPlusBot.Features.Connections.Tests;
24+
25+
public sealed class SwitchPrimingTests
26+
{
27+
private static (ServiceProvider Provider, ConnectionSupervisor Supervisor, InMemoryEventBus Bus) CreateHarness(
28+
FakeRustSocketSource source)
29+
{
30+
var protector = Substitute.For<ICredentialProtector>();
31+
protector.Unprotect(Arg.Any<string>()).Returns(c => c.Arg<string>());
32+
var dm = Substitute.For<IUserDmSender>();
33+
var clock = Substitute.For<IClock>();
34+
clock.UtcNow.Returns(DateTimeOffset.UnixEpoch);
35+
var bus = new InMemoryEventBus();
36+
37+
var services = new ServiceCollection();
38+
services.AddLogging();
39+
services.AddSingleton(clock);
40+
services.AddSingleton(protector);
41+
services.AddSingleton(dm);
42+
services.AddSingleton<IEventBus>(bus);
43+
44+
var cs = $"DataSource=switchpriming-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
45+
var keepAlive = new SqliteConnection(cs);
46+
keepAlive.Open();
47+
using (var seed = new BotDbContext(new DbContextOptionsBuilder<BotDbContext>().UseSqlite(cs).Options))
48+
{
49+
seed.Database.Migrate();
50+
}
51+
52+
services.AddSingleton(keepAlive);
53+
services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder<BotDbContext>().UseSqlite(cs).Options));
54+
services.AddScoped<IConnectionStore, ConnectionStore>();
55+
services.AddScoped<IServerService, ServerService>();
56+
services.AddScoped<ISwitchStore, SwitchStore>();
57+
services.AddScoped<IAlarmStore, AlarmStore>();
58+
services.AddScoped<IStorageMonitorStore, StorageMonitorStore>();
59+
services.AddSingleton<IRustSocketSource>(source);
60+
services.AddSingleton(Options.Create(new ConnectionOptions
61+
{
62+
ConnectTimeout = TimeSpan.FromSeconds(1),
63+
InitialRetryDelay = TimeSpan.FromMilliseconds(5),
64+
MaxRetryDelay = TimeSpan.FromMilliseconds(20),
65+
HeartbeatInterval = TimeSpan.FromMilliseconds(20),
66+
HeartbeatTimeout = TimeSpan.FromMilliseconds(200),
67+
}));
68+
services.AddSingleton<ConnectionSecurity>();
69+
services.AddSingleton<ConnectionSupervisor>();
70+
71+
var provider = services.BuildServiceProvider();
72+
return (provider, provider.GetRequiredService<ConnectionSupervisor>(), bus);
73+
}
74+
75+
private static async Task<Guid> SeedServerWithActiveAndSwitchAsync(ServiceProvider provider, ulong entityId)
76+
{
77+
using var scope = provider.CreateScope();
78+
var ctx = scope.ServiceProvider.GetRequiredService<BotDbContext>();
79+
var server = new RustServer
80+
{
81+
GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015
82+
};
83+
ctx.RustServers.Add(server);
84+
ctx.PlayerCredentials.Add(new PlayerCredential
85+
{
86+
GuildId = 10UL,
87+
RustServerId = server.Id,
88+
OwnerUserId = 1UL,
89+
SteamId = 555UL,
90+
ProtectedPlayerToken = "123",
91+
Status = CredentialStatus.Active,
92+
});
93+
await ctx.SaveChangesAsync();
94+
var store = scope.ServiceProvider.GetRequiredService<ISwitchStore>();
95+
await store.AddAsync(10UL, server.Id, entityId, $"Switch {entityId}", 1UL);
96+
return server.Id;
97+
}
98+
99+
private static async Task WaitUntilAsync(Func<bool> condition, CancellationToken ct)
100+
{
101+
while (!condition())
102+
{
103+
ct.ThrowIfCancellationRequested();
104+
await Task.Delay(10, ct);
105+
}
106+
}
107+
108+
[Fact]
109+
public async Task Prime_RemovedSwitch_PublishesRemovedReachability_AndNoActiveState()
110+
{
111+
// Arrange: a managed switch exists; the fake connection returns a Removed reading for it.
112+
var source = new FakeRustSocketSource();
113+
var (provider, supervisor, bus) = CreateHarness(source);
114+
await using var disposeProvider = provider;
115+
var serverId = await SeedServerWithActiveAndSwitchAsync(provider, entityId: 42UL);
116+
117+
// Configure the fake's reachability override so GetSmartDeviceInfoAsync returns Removed for entity 42.
118+
// This must be staged before EnsureConnectionAsync so it's in place when the prime loop runs.
119+
// FakeConnection is created inside Create(), so we stage via the source's pre-connect hook, but
120+
// DeviceReachabilityOverrides is on FakeConnection — we set it after the first Create() via
121+
// LastConnection. However, Create() fires during EnsureConnectionAsync (inside the background task),
122+
// so we can't access LastConnection before EnsureConnectionAsync completes. Instead we use
123+
// a connect-outcome hook: stage Connected so the connection is created; then wait for HasLiveSocket.
124+
//
125+
// To avoid the race, we subscribe to DeviceReachabilityChangedEvent before connecting and
126+
// set the override on the source's pending-storage approach is not available for switches.
127+
// The brief says to use DeviceReachabilityOverrides — set it before EnsureConnectionAsync
128+
// by using a custom FakeRustSocketSource subclass or by seeding it via a staging dictionary.
129+
//
130+
// The cleanest race-free approach: subscribe first, then EnsureConnectionAsync, then wait
131+
// for HasLiveSocket, then check published events — but priming fires BEFORE HasLiveSocket
132+
// check is possible via polling. We use the same approach as AlarmPrimingTests: subscribe
133+
// to the reachability event (which fires only after the new prime code), wait for it, then
134+
// assert absence of SmartDeviceTriggeredEvent{IsActive:false}.
135+
//
136+
// To stage the override before prime: add a pre-stage dictionary on FakeRustSocketSource
137+
// analogous to _pendingStorageContents (which transfers to FakeConnection at Create time).
138+
// However the brief says the fake already has DeviceReachabilityOverrides and to "use it".
139+
// Per the brief Task 4 note: "Read that fake to learn its exact API."
140+
// FakeRustSocketSource already has _pendingStorageContents transferred at Create time;
141+
// FakeConnection.DeviceReachabilityOverrides exists but has no pre-stage path in the source.
142+
// We add a minimal PendingDeviceReachabilityOverrides staging dict to FakeRustSocketSource
143+
// (transferred at Create time, like _pendingStorageContents) — documented in the report.
144+
source.StageDeviceReachability(42UL, DeviceReachability.Removed);
145+
146+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
147+
148+
// Capture all published events on two channels.
149+
var reachabilityEvents = new System.Collections.Concurrent.ConcurrentQueue<DeviceReachabilityChangedEvent>();
150+
var triggeredEvents = new System.Collections.Concurrent.ConcurrentQueue<SmartDeviceTriggeredEvent>();
151+
152+
var reachSub = bus.SubscribeAsync<DeviceReachabilityChangedEvent>(cts.Token);
153+
var trigSub = bus.SubscribeAsync<SmartDeviceTriggeredEvent>(cts.Token);
154+
155+
_ = Task.Run(async () =>
156+
{
157+
await foreach (var e in reachSub)
158+
{
159+
reachabilityEvents.Enqueue(e);
160+
}
161+
}, CancellationToken.None);
162+
163+
_ = Task.Run(async () =>
164+
{
165+
await foreach (var e in trigSub)
166+
{
167+
triggeredEvents.Enqueue(e);
168+
}
169+
}, CancellationToken.None);
170+
171+
// Act: drive the connect/prime path.
172+
await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token);
173+
174+
// Wait for the reachability event for entity 42 to be published (definite signal that prime completed).
175+
await WaitUntilAsync(() => reachabilityEvents.Any(e => e.EntityId == 42UL), cts.Token);
176+
177+
// Give a moment for any spurious SmartDeviceTriggeredEvent to appear if incorrectly published.
178+
await Task.Delay(TimeSpan.FromMilliseconds(100), cts.Token);
179+
180+
// Assert: DeviceReachabilityChangedEvent{EntityId=42, Reachability=Removed} was published.
181+
Assert.Contains(reachabilityEvents, e =>
182+
e is { EntityId: 42UL, Reachability: DeviceReachability.Removed });
183+
184+
// Assert: SmartDeviceTriggeredEvent{EntityId=42, IsActive=false} was NOT published.
185+
Assert.DoesNotContain(triggeredEvents, e =>
186+
e is { EntityId: 42UL, IsActive: false });
187+
188+
await supervisor.StopAllAsync();
189+
}
190+
}

0 commit comments

Comments
 (0)