@@ -519,6 +519,8 @@ void OnStorage(object? sender, StorageMonitorTrigger trigger)
519519 using var pollCts = CancellationTokenSource . CreateLinkedTokenSource ( ct ) ;
520520 var markerPoll = Task . Run ( ( ) => PollMarkersAsync ( key , connection , dims , rigs , tracker , pollCts . Token ) ,
521521 CancellationToken . None ) ;
522+ var reachabilityPoll = Task . Run ( ( ) => PollReachabilityAsync ( key , connection , pollCts . Token ) ,
523+ CancellationToken . None ) ;
522524 try
523525 {
524526 return await RunHeartbeatLoopAsync ( key , connection , credentialId , ct ) . ConfigureAwait ( false ) ;
@@ -528,8 +530,8 @@ void OnStorage(object? sender, StorageMonitorTrigger trigger)
528530 await pollCts . CancelAsync ( ) . ConfigureAwait ( false ) ;
529531 try
530532 {
531- #pragma warning disable VSTHRD003 // Suppress: markerPoll is owned by this connected window and explicitly joined on exit.
532- await markerPoll . ConfigureAwait ( false ) ;
533+ #pragma warning disable VSTHRD003 // Suppress: markerPoll and reachabilityPoll are owned by this connected window and explicitly joined on exit.
534+ await Task . WhenAll ( markerPoll , reachabilityPoll ) . ConfigureAwait ( false ) ;
533535#pragma warning restore VSTHRD003
534536 }
535537 catch ( OperationCanceledException )
@@ -632,6 +634,91 @@ await eventBus.PublishAsync(
632634 }
633635 }
634636
637+ private async Task PollReachabilityAsync (
638+ ( ulong Guild , Guid Server ) key ,
639+ IRustServerConnection connection ,
640+ CancellationToken ct )
641+ {
642+ var previous = new Dictionary < ulong , DeviceReachability > ( ) ;
643+ var seeded = false ;
644+ while ( ! ct . IsCancellationRequested )
645+ {
646+ await Task . Delay ( _options . ReachabilityPollInterval , ct ) . ConfigureAwait ( false ) ;
647+ Dictionary < ulong , DeviceReachability > current ;
648+ try
649+ {
650+ current = await ReadAllReachabilityAsync ( key , connection , ct ) . ConfigureAwait ( false ) ;
651+ }
652+ catch ( OperationCanceledException )
653+ {
654+ throw ;
655+ }
656+ #pragma warning disable CA1031 // Broad catch: a failed reachability sweep is logged and retried next cycle.
657+ catch ( Exception ex )
658+ #pragma warning restore CA1031
659+ {
660+ LogReachabilityPollFailed ( logger , ex , key . Server ) ;
661+ continue ;
662+ }
663+
664+ if ( ! seeded )
665+ {
666+ foreach ( var kvp in current )
667+ {
668+ previous [ kvp . Key ] = kvp . Value ;
669+ }
670+
671+ seeded = true ;
672+ continue ; // first cycle: silent baseline
673+ }
674+
675+ foreach ( var change in ReachabilitySweep . Diff ( previous , current ) )
676+ {
677+ previous [ change . Key ] = change . Value ;
678+ await eventBus . PublishAsync (
679+ new DeviceReachabilityChangedEvent ( key . Guild , key . Server , change . Key , change . Value ) , ct )
680+ . ConfigureAwait ( false ) ;
681+ }
682+ }
683+ }
684+
685+ private async Task < Dictionary < ulong , DeviceReachability > > ReadAllReachabilityAsync (
686+ ( ulong Guild , Guid Server ) key ,
687+ IRustServerConnection connection ,
688+ CancellationToken ct )
689+ {
690+ var result = new Dictionary < ulong , DeviceReachability > ( ) ;
691+ var scope = scopeFactory . CreateAsyncScope ( ) ;
692+ await using ( scope . ConfigureAwait ( false ) )
693+ {
694+ var switches = await scope . ServiceProvider . GetRequiredService < ISwitchStore > ( )
695+ . ListByServerAsync ( key . Guild , key . Server , ct ) . ConfigureAwait ( false ) ;
696+ var alarms = await scope . ServiceProvider . GetRequiredService < IAlarmStore > ( )
697+ . ListByServerAsync ( key . Guild , key . Server , ct ) . ConfigureAwait ( false ) ;
698+ var monitors = await scope . ServiceProvider . GetRequiredService < IStorageMonitorStore > ( )
699+ . ListByServerAsync ( key . Guild , key . Server , ct ) . ConfigureAwait ( false ) ;
700+
701+ foreach ( var entityId in switches . Select ( s => s . EntityId ) . Concat ( alarms . Select ( a => a . EntityId ) ) )
702+ {
703+ var reading = await connection . GetSmartDeviceInfoAsync ( entityId , _options . HeartbeatTimeout , ct )
704+ . ConfigureAwait ( false ) ;
705+ result [ entityId ] = reading . Reachability ;
706+ }
707+
708+ #pragma warning disable S3267 // Not a projection: each iteration awaits with per-monitor best-effort error handling.
709+ foreach ( var monitor in monitors )
710+ #pragma warning restore S3267
711+ {
712+ var reading = await connection
713+ . GetStorageMonitorInfoAsync ( monitor . EntityId , _options . HeartbeatTimeout , ct )
714+ . ConfigureAwait ( false ) ;
715+ result [ monitor . EntityId ] = reading . Reachability ;
716+ }
717+ }
718+
719+ return result ;
720+ }
721+
635722 private async Task DetectRigActivationsAsync (
636723 ( ulong Guild , Guid Server ) key ,
637724 IReadOnlyList < MapMarkerSnapshot > current ,
@@ -1118,6 +1205,9 @@ await eventBus.PublishAsync(
11181205 [ LoggerMessage ( Level = LogLevel . Warning , Message = "Marker poll for server {ServerId} failed." ) ]
11191206 private static partial void LogMarkerPollFailed ( ILogger logger , Exception exception , Guid serverId ) ;
11201207
1208+ [ LoggerMessage ( Level = LogLevel . Warning , Message = "Reachability poll for server {ServerId} failed." ) ]
1209+ private static partial void LogReachabilityPollFailed ( ILogger logger , Exception exception , Guid serverId ) ;
1210+
11211211 [ LoggerMessage ( Level = LogLevel . Warning ,
11221212 Message =
11231213 "Fetching monuments for oil-rig detection on server {ServerId} failed; rig detection disabled for this connection." ) ]
0 commit comments