Skip to content

Commit 861c30a

Browse files
HandyS11claude
andcommitted
feat(workspace): re-render #info on ConnectionStatusChangedEvent
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 71039a1 commit 861c30a

3 files changed

Lines changed: 84 additions & 5 deletions

File tree

src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ internal sealed class WorkspaceHostedService(
2020
ILogger<WorkspaceHostedService> logger) : IHostedService, IDisposable
2121
{
2222
private readonly CancellationTokenSource _cts = new();
23-
private Task? _eventLoop;
23+
private Task? _serverRegisteredLoop;
24+
private Task? _connectionStatusLoop;
2425
private bool _startupDone;
2526

2627
/// <inheritdoc />
@@ -31,7 +32,8 @@ public Task StartAsync(CancellationToken cancellationToken)
3132
{
3233
client.Ready += OnReadyAsync;
3334
client.ChannelDestroyed += OnChannelDestroyedAsync;
34-
_eventLoop = Task.Run(() => ConsumeServerRegisteredAsync(_cts.Token), CancellationToken.None);
35+
_serverRegisteredLoop = Task.Run(() => ConsumeServerRegisteredAsync(_cts.Token), CancellationToken.None);
36+
_connectionStatusLoop = Task.Run(() => ConsumeConnectionStatusAsync(_cts.Token), CancellationToken.None);
3537
return Task.CompletedTask;
3638
}
3739

@@ -41,12 +43,17 @@ public async Task StopAsync(CancellationToken cancellationToken)
4143
client.Ready -= OnReadyAsync;
4244
client.ChannelDestroyed -= OnChannelDestroyedAsync;
4345
await _cts.CancelAsync().ConfigureAwait(false);
44-
if (_eventLoop is not null)
46+
foreach (var loop in new[] { _serverRegisteredLoop, _connectionStatusLoop })
4547
{
48+
if (loop is null)
49+
{
50+
continue;
51+
}
52+
4653
try
4754
{
48-
#pragma warning disable VSTHRD003 // Avoid awaiting or returning a Task representing work that was not started within your context
49-
await _eventLoop.ConfigureAwait(false);
55+
#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks — these are our own loop tasks, joined on stop.
56+
await loop.ConfigureAwait(false);
5057
#pragma warning restore VSTHRD003
5158
}
5259
catch (OperationCanceledException)
@@ -98,6 +105,34 @@ private async Task OnChannelDestroyedAsync(SocketChannel channel)
98105
}
99106
}
100107

108+
private async Task ConsumeConnectionStatusAsync(CancellationToken cancellationToken)
109+
{
110+
// If this loop faults (broad catch), the consumer exits permanently and info channels stop
111+
// updating until the host restarts. Acceptable: the reconciler is idempotent and a restart heals.
112+
try
113+
{
114+
await foreach (var changed in eventBus.SubscribeAsync<ConnectionStatusChangedEvent>(cancellationToken)
115+
.ConfigureAwait(false))
116+
{
117+
var scope = scopeFactory.CreateAsyncScope();
118+
await using (scope.ConfigureAwait(false))
119+
{
120+
var reconciler = scope.ServiceProvider.GetRequiredService<IWorkspaceReconciler>();
121+
await reconciler.ReconcileServerAsync(changed.GuildId, changed.ServerId, cancellationToken)
122+
.ConfigureAwait(false);
123+
}
124+
}
125+
}
126+
catch (OperationCanceledException)
127+
{
128+
// Shutting down.
129+
}
130+
catch (Exception ex) // Broad catch is intentional: a faulting consumer must not crash the host.
131+
{
132+
logger.LogError(ex, "ConnectionStatusChanged consumer faulted.");
133+
}
134+
}
135+
101136
private async Task ConsumeServerRegisteredAsync(CancellationToken cancellationToken)
102137
{
103138
// Subscription is registered when this loop first calls SubscribeAsync; the in-process bus does

src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
<ItemGroup>
44
<InternalsVisibleTo Include="RustPlusBot.Features.Workspace.Tests" />
5+
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
56
</ItemGroup>
67

78
<ItemGroup>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using Discord.WebSocket;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using Microsoft.Extensions.Logging.Abstractions;
4+
using NSubstitute;
5+
using RustPlusBot.Abstractions.Events;
6+
using RustPlusBot.Features.Workspace.Hosting;
7+
using RustPlusBot.Features.Workspace.Reconciler;
8+
9+
namespace RustPlusBot.Features.Workspace.Tests.Hosting;
10+
11+
public sealed class WorkspaceConnectionStatusTests
12+
{
13+
[Fact]
14+
public async Task ConnectionStatusChanged_ReconcilesThatServer()
15+
{
16+
var reconciler = Substitute.For<IWorkspaceReconciler>();
17+
var services = new ServiceCollection();
18+
services.AddScoped(_ => reconciler);
19+
await using var provider = services.BuildServiceProvider();
20+
21+
var bus = new InMemoryEventBus();
22+
var client = new DiscordSocketClient();
23+
var service = new WorkspaceHostedService(client, bus,
24+
provider.GetRequiredService<IServiceScopeFactory>(),
25+
NullLogger<WorkspaceHostedService>.Instance);
26+
27+
await service.StartAsync(default);
28+
var serverId = Guid.NewGuid();
29+
// The in-process bus does not replay; re-publish until the consumer has reconciled (or deadline).
30+
var deadline = DateTimeOffset.UtcNow.AddSeconds(20);
31+
while (DateTimeOffset.UtcNow < deadline
32+
&& !reconciler.ReceivedCalls().Any(c =>
33+
c.GetMethodInfo().Name == nameof(IWorkspaceReconciler.ReconcileServerAsync)))
34+
{
35+
await bus.PublishAsync(new ConnectionStatusChangedEvent(10UL, serverId));
36+
await Task.Delay(20);
37+
}
38+
39+
await reconciler.Received().ReconcileServerAsync(10UL, serverId, Arg.Any<CancellationToken>());
40+
41+
await service.StopAsync(default);
42+
}
43+
}

0 commit comments

Comments
 (0)