@@ -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
0 commit comments