Skip to content

Commit 62b6f26

Browse files
committed
fix(1b-iii): address PR review (resilient consumers, set-based delete, DI forwarding, migration orphan guard)
1 parent 5d470f6 commit 62b6f26

6 files changed

Lines changed: 107 additions & 18 deletions

File tree

src/RustPlusBot.Features.Connections/Hosting/ConnectionHostedService.cs

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,22 +76,48 @@ await Task.WhenAll(
7676

7777
private async Task ConsumeRegisteredAsync(CancellationToken cancellationToken)
7878
{
79-
await foreach (var registered in eventBus.SubscribeAsync<ServerRegisteredEvent>(cancellationToken)
80-
.ConfigureAwait(false))
79+
try
8180
{
82-
await supervisor.EnsureConnectionAsync(registered.GuildId, registered.ServerId, cancellationToken)
83-
.ConfigureAwait(false);
81+
await foreach (var registered in eventBus.SubscribeAsync<ServerRegisteredEvent>(cancellationToken)
82+
.ConfigureAwait(false))
83+
{
84+
await supervisor.EnsureConnectionAsync(registered.GuildId, registered.ServerId, cancellationToken)
85+
.ConfigureAwait(false);
86+
}
8487
}
88+
catch (OperationCanceledException)
89+
{
90+
// Shutting down.
91+
}
92+
#pragma warning disable CA1031 // Broad catch is intentional: one faulting consumer must not stop the other or crash the host.
93+
catch (Exception ex)
94+
{
95+
LogLoopFaulted(logger, ex);
96+
}
97+
#pragma warning restore CA1031
8598
}
8699

87100
private async Task ConsumeCredentialsChangedAsync(CancellationToken cancellationToken)
88101
{
89-
await foreach (var changed in eventBus.SubscribeAsync<ServerCredentialsChangedEvent>(cancellationToken)
90-
.ConfigureAwait(false))
102+
try
91103
{
92-
await supervisor.EnsureConnectionAsync(changed.GuildId, changed.ServerId, cancellationToken)
93-
.ConfigureAwait(false);
104+
await foreach (var changed in eventBus.SubscribeAsync<ServerCredentialsChangedEvent>(cancellationToken)
105+
.ConfigureAwait(false))
106+
{
107+
await supervisor.EnsureConnectionAsync(changed.GuildId, changed.ServerId, cancellationToken)
108+
.ConfigureAwait(false);
109+
}
94110
}
111+
catch (OperationCanceledException)
112+
{
113+
// Shutting down.
114+
}
115+
#pragma warning disable CA1031 // Broad catch is intentional: one faulting consumer must not stop the other or crash the host.
116+
catch (Exception ex)
117+
{
118+
LogLoopFaulted(logger, ex);
119+
}
120+
#pragma warning restore CA1031
95121
}
96122

97123
[LoggerMessage(Level = LogLevel.Error, Message = "Connection hosted-service loop faulted.")]

src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,15 @@ public async ValueTask<MessagePayload> RenderAsync(MessageRenderContext context,
7777
select.AddOption(label, credential.Id.ToString(), isDefault: credential.Id == active?.Id);
7878
}
7979

80-
builder.WithSelectMenu(select);
80+
builder.WithSelectMenu(select, row: 0);
8181
}
8282

83+
// Keep the remove button on its own row: Discord rejects an action row that mixes a select with buttons.
8384
builder.WithButton(
8485
localizer.Get("server.info.remove.button", context.Culture),
8586
$"{WorkspaceComponentIds.ServerInfoRemovePrefix}{serverId}",
86-
ButtonStyle.Danger);
87+
ButtonStyle.Danger,
88+
row: eligible.Count > 0 ? 1 : 0);
8789

8890
return new MessagePayload(null, embed.Build(), builder.Build());
8991
}

src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,12 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services)
4141
services.AddScoped<IMessageRenderer, SettingsMessageRenderer>();
4242
services.AddScoped<IMessageRenderer, ServerInfoMessageRenderer>();
4343

44-
// Reconciler + teardown (scoped).
44+
// Reconciler + teardown (scoped). Register the teardown service once and expose both interfaces
45+
// off the same scoped instance, so resolving either does not create a second instance.
4546
services.AddScoped<IWorkspaceReconciler, WorkspaceReconciler>();
46-
services.AddScoped<IWorkspaceTeardownService, WorkspaceTeardownService>();
47-
services.AddScoped<IServerWorkspaceRemover, WorkspaceTeardownService>();
47+
services.AddScoped<WorkspaceTeardownService>();
48+
services.AddScoped<IWorkspaceTeardownService>(sp => sp.GetRequiredService<WorkspaceTeardownService>());
49+
services.AddScoped<IServerWorkspaceRemover>(sp => sp.GetRequiredService<WorkspaceTeardownService>());
4850

4951
// Options (Host binds the "Workspace" section; default = danger commands off).
5052
services.AddOptions<WorkspaceOptions>();

src/RustPlusBot.Persistence/Credentials/CredentialStore.cs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,18 +68,22 @@ public async Task<IReadOnlyList<Guid>> RemoveForOwnerAsync(
6868
ulong ownerUserId,
6969
CancellationToken cancellationToken = default)
7070
{
71-
var owned = await context.PlayerCredentials
71+
// Project the affected server ids first (no token material loaded), then delete set-based.
72+
var serverIds = await context.PlayerCredentials
7273
.Where(c => c.GuildId == guildId && c.OwnerUserId == ownerUserId)
74+
.Select(c => c.RustServerId)
75+
.Distinct()
7376
.ToListAsync(cancellationToken)
7477
.ConfigureAwait(false);
75-
if (owned.Count == 0)
78+
if (serverIds.Count == 0)
7679
{
7780
return [];
7881
}
7982

80-
var serverIds = owned.Select(c => c.RustServerId).Distinct().ToList();
81-
context.PlayerCredentials.RemoveRange(owned);
82-
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
83+
await context.PlayerCredentials
84+
.Where(c => c.GuildId == guildId && c.OwnerUserId == ownerUserId)
85+
.ExecuteDeleteAsync(cancellationToken)
86+
.ConfigureAwait(false);
8387
return serverIds;
8488
}
8589

src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ public partial class ServerRemovalCascade : Migration
1010
/// <inheritdoc />
1111
protected override void Up(MigrationBuilder migrationBuilder)
1212
{
13+
// Defensive: remove any ConnectionStates orphaned before this FK existed so adding the
14+
// constraint cannot fail. No server-removal path existed before 1b-iii, so this is normally a no-op.
15+
migrationBuilder.Sql(
16+
"DELETE FROM \"ConnectionStates\" WHERE \"RustServerId\" NOT IN (SELECT \"Id\" FROM \"RustServers\");");
17+
1318
migrationBuilder.AddForeignKey(
1419
name: "FK_ConnectionStates_RustServers_RustServerId",
1520
table: "ConnectionStates",

tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,4 +225,54 @@ public async Task ServerInfo_HasRemoveServerButton()
225225
.SelectMany(r => r.Components).OfType<ButtonComponent>();
226226
Assert.Contains(buttons, b => b.CustomId == $"workspace:info:remove:{serverId}");
227227
}
228+
229+
[Fact]
230+
public async Task ServerInfo_SwapSelectAndRemoveButton_AreInSeparateActionRows()
231+
{
232+
var serverId = Guid.NewGuid();
233+
var credId = Guid.NewGuid();
234+
var servers = Substitute.For<IServerService>();
235+
servers.GetAsync(1, serverId, Arg.Any<CancellationToken>())
236+
.Returns(new RustServer
237+
{
238+
Id = serverId,
239+
GuildId = 1,
240+
Name = "S",
241+
Ip = "1.2.3.4",
242+
Port = 28015
243+
});
244+
var connections = Substitute.For<IConnectionStore>();
245+
connections.GetStateAsync(1, serverId, Arg.Any<CancellationToken>())
246+
.Returns(new DomainConnectionState
247+
{
248+
RustServerId = serverId,
249+
GuildId = 1,
250+
ActiveCredentialId = credId,
251+
Status = ConnectionStatus.Connected,
252+
PlayerCount = 1,
253+
});
254+
connections.ListPoolAsync(1, serverId, Arg.Any<CancellationToken>())
255+
.Returns(new List<PlayerCredential>
256+
{
257+
new()
258+
{
259+
Id = credId,
260+
GuildId = 1,
261+
RustServerId = serverId,
262+
OwnerUserId = 7,
263+
SteamId = 5UL,
264+
Status = CredentialStatus.Active
265+
},
266+
});
267+
var renderer = new ServerInfoMessageRenderer(servers, connections, Loc);
268+
269+
var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default);
270+
271+
var rows = payload.Components!.Components.OfType<ActionRowComponent>().ToList();
272+
// Discord rejects an action row that mixes a select menu with buttons.
273+
Assert.DoesNotContain(rows, r =>
274+
r.Components.OfType<SelectMenuComponent>().Any() && r.Components.OfType<ButtonComponent>().Any());
275+
Assert.Contains(rows, r => r.Components.OfType<SelectMenuComponent>().Any());
276+
Assert.Contains(rows, r => r.Components.OfType<ButtonComponent>().Any());
277+
}
228278
}

0 commit comments

Comments
 (0)