Skip to content

Commit c906a92

Browse files
HandyS11claude
andcommitted
Make the workspace registry guard actually fail startup fast
WorkspaceHostedService.StartAsync now resolves IWorkspaceRegistry itself, outside any try/catch, before scheduling any heal work. Previously the registry's constructor guard against an unprovided channel capability was only ever triggered lazily from inside broad catches (the startup heal and each event-loop consumer), so a host composing AddWorkspace() without the module owning a capability would start cleanly and only fault quietly on the first reconcile, contradicting the guard's own comment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 359ccfb commit c906a92

3 files changed

Lines changed: 65 additions & 0 deletions

File tree

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Microsoft.Extensions.Logging;
55
using RustPlusBot.Abstractions.Events;
66
using RustPlusBot.Features.Workspace.Reconciler;
7+
using RustPlusBot.Features.Workspace.Registry;
78
using RustPlusBot.Persistence.Workspace;
89

910
namespace RustPlusBot.Features.Workspace.Hosting;
@@ -32,6 +33,17 @@ internal sealed class WorkspaceHostedService(
3233
/// <inheritdoc />
3334
public Task StartAsync(CancellationToken cancellationToken)
3435
{
36+
// Force the workspace registry's construction now, synchronously, before any heal work is
37+
// queued. Its constructor throws when a channel spec names a capability with no registered
38+
// provider. Every other place below resolves it lazily inside a broad catch, so a misconfigured
39+
// host would otherwise start cleanly and only fault quietly on the first reconcile. Resolving it
40+
// here, outside any try or catch, lets that exception propagate out of this method so the host
41+
// genuinely fails to start instead.
42+
using (var scope = scopeFactory.CreateScope())
43+
{
44+
scope.ServiceProvider.GetRequiredService<IWorkspaceRegistry>();
45+
}
46+
3547
client.Ready += OnReadyAsync;
3648
client.ChannelDestroyed += OnChannelDestroyedAsync;
3749
_serverRegisteredLoop = Task.Run(() => ConsumeServerRegisteredAsync(_cts.Token), CancellationToken.None);

tests/RustPlusBot.Features.Workspace.Tests/Hosting/WorkspaceConnectionStatusTests.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using RustPlusBot.Abstractions.Events;
66
using RustPlusBot.Features.Workspace.Hosting;
77
using RustPlusBot.Features.Workspace.Reconciler;
8+
using RustPlusBot.Features.Workspace.Registry;
89

910
namespace RustPlusBot.Features.Workspace.Tests.Hosting;
1011

@@ -16,6 +17,9 @@ public async Task ConnectionStatusChanged_ReconcilesThatServer()
1617
var reconciler = Substitute.For<IWorkspaceReconciler>();
1718
var services = new ServiceCollection();
1819
services.AddScoped(_ => reconciler);
20+
// StartAsync now resolves IWorkspaceRegistry up front (see the startup guard test); this
21+
// minimal container needs a stand-in so that resolution succeeds.
22+
services.AddSingleton(Substitute.For<IWorkspaceRegistry>());
1923
await using var provider = services.BuildServiceProvider();
2024

2125
var bus = new InMemoryEventBus();
@@ -48,6 +52,9 @@ public async Task ServerCredentialsChanged_ReconcilesThatServer()
4852
var reconciler = Substitute.For<IWorkspaceReconciler>();
4953
var services = new ServiceCollection();
5054
services.AddScoped(_ => reconciler);
55+
// StartAsync now resolves IWorkspaceRegistry up front (see the startup guard test); this
56+
// minimal container needs a stand-in so that resolution succeeds.
57+
services.AddSingleton(Substitute.For<IWorkspaceRegistry>());
5158
await using var provider = services.BuildServiceProvider();
5259

5360
var bus = new InMemoryEventBus();
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using Discord.WebSocket;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using Microsoft.Extensions.Logging.Abstractions;
4+
using NSubstitute;
5+
using RustPlusBot.Abstractions.Connections;
6+
using RustPlusBot.Abstractions.Events;
7+
using RustPlusBot.Abstractions.Time;
8+
using RustPlusBot.Features.Workspace.Hosting;
9+
using RustPlusBot.Persistence;
10+
11+
namespace RustPlusBot.Features.Workspace.Tests.Hosting;
12+
13+
public sealed class WorkspaceHostedServiceStartupGuardTests
14+
{
15+
[Fact]
16+
public async Task StartAsync_throws_when_a_gated_capability_has_no_provider()
17+
{
18+
// Mirrors a host that composes AddWorkspace() without the module (e.g. AddClans()) that owns
19+
// the "clan" capability. WorkspaceRegistry's constructor guards against this because the
20+
// reconciler would otherwise treat the gated channels as unavailable and delete them; this test
21+
// proves the host now fails fast at StartAsync instead of starting and faulting quietly later.
22+
var services = new ServiceCollection();
23+
services.AddSingleton<IClock, SystemClock>();
24+
services.AddSingleton<IEventBus, InMemoryEventBus>();
25+
services.AddSingleton(Substitute.For<IRustServerQuery>());
26+
services.AddLogging();
27+
services.AddBotPersistence("DataSource=:memory:");
28+
services.AddWorkspace();
29+
// Deliberately no IWorkspaceCapabilityProvider registered for "clan".
30+
31+
await using var provider = services.BuildServiceProvider(new ServiceProviderOptions
32+
{
33+
ValidateScopes = true
34+
});
35+
36+
var client = new DiscordSocketClient();
37+
var hostedService = new WorkspaceHostedService(
38+
client,
39+
provider.GetRequiredService<IEventBus>(),
40+
provider.GetRequiredService<IServiceScopeFactory>(),
41+
NullLogger<WorkspaceHostedService>.Instance);
42+
43+
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => hostedService.StartAsync(default));
44+
Assert.Contains("clan", ex.Message, StringComparison.OrdinalIgnoreCase);
45+
}
46+
}

0 commit comments

Comments
 (0)