diff --git a/src/Infrastructure/OpcUa/ExperionRealtimeService.cs b/src/Infrastructure/OpcUa/ExperionRealtimeService.cs index b79039c..d081465 100644 --- a/src/Infrastructure/OpcUa/ExperionRealtimeService.cs +++ b/src/Infrastructure/OpcUa/ExperionRealtimeService.cs @@ -256,8 +256,9 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService, // 실패/이상 → RetryDelay 후 무한 재시도 (3회-후-포기 없음, 주기적 자동 재개) private async Task SuperviseAsync(CancellationToken ct) { - const int RetryDelayMs = 30_000; // 연결 실패/이상 후 재시도 주기 - const int HealthPollMs = 5_000; // 건강 점검 주기 + const int RetryDelayMs = 10_000; // 연결 실패/이상 후 재시도 주기 (리던던트 서버 전환 등 빠른 재포착) + const int ConnectTimeoutMs = 10_000; // 1회 연결 시도 최대 시간 (죽은 서버에 무한 블로킹 방지) + const int HealthPollMs = 5_000; // 건강 점검 주기 // flush 루프는 supervisor 수명 동안 단 1회만 기동 (재연결마다 누적되던 버그 차단) _flushTask = Task.Run(() => FlushLoopAsync(ct), ct); @@ -266,7 +267,14 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService, { try { - await ConnectAndSubscribeAsync(ct); + // 1회 연결 시도에 타임아웃을 건다(죽은/전환중 서버에 길게 묶이지 않게). + // 타임아웃 시 OCE/TimeoutException이 나지만 우리(ct) 취소가 아니므로 아래 일반 catch에서 재시도된다. + using (var connCts = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + connCts.CancelAfter(ConnectTimeoutMs); + await ConnectAndSubscribeAsync(connCts.Token) + .WaitAsync(TimeSpan.FromMilliseconds(ConnectTimeoutMs + 2_000), ct); // SDK가 토큰 무시해도 하드 백스톱 + } // 연결 유지 동안 건강 감시 string? fault = null; @@ -282,14 +290,18 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService, _logger.LogWarning("[Realtime] 링크 이상 감지 ({Fault}) — {Sec}초 후 재연결", _linkFault, RetryDelayMs / 1000); await CleanupSessionAsync(); } - catch (OperationCanceledException) { break; } + // 우리(앱 종료/운전원 정지)가 취소한 경우에만 루프 종료. + catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } + // OPC SDK 연결/세션 타임아웃은 TaskCanceledException(=OCE 하위)으로 던져진다. + // 우리 취소가 아니면 일반 오류와 동일하게 재시도해야 함. + // (이전: catch(OCE){break} 가 SDK 타임아웃을 우리 취소로 오인 → '중지됨' 으로 죽어 복구 불가 버그) catch (Exception ex) { _running = false; _stalled = true; _linkFault = ex.Message; _statusMsg = $"재연결 대기 중: {ex.Message}"; - _logger.LogWarning(ex, "[Realtime] 연결 오류 — {Sec}초 후 재시도", RetryDelayMs / 1000); + _logger.LogWarning(ex, "[Realtime] 연결 오류({Type}) — {Sec}초 후 재시도", ex.GetType().Name, RetryDelayMs / 1000); await CleanupSessionAsync(); } @@ -310,7 +322,7 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService, var appConfig = await BuildConfigAsync(_currentCfg); var endpoint = await SelectEndpointAsync(appConfig, _currentCfg.EndpointUrl, ct); - _session = await CreateSessionAsync(appConfig, endpoint, _currentCfg); + _session = await CreateSessionAsync(appConfig, endpoint, _currentCfg, ct); _logger.LogInformation("[Realtime] 세션 생성 완료"); @@ -500,7 +512,8 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService, private static async Task CreateSessionAsync( ApplicationConfiguration appConfig, ConfiguredEndpoint endpoint, - ExperionServerConfig cfg) + ExperionServerConfig cfg, + CancellationToken ct = default) { var identity = new UserIdentity(cfg.UserName, Encoding.UTF8.GetBytes(cfg.Password)); #pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type @@ -512,7 +525,7 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService, 60_000, identity, null, - CancellationToken.None); + ct); // 연결 타임아웃 토큰 전달 (이전 CancellationToken.None → 죽은 서버에 ~2분 블로킹 원인) #pragma warning restore CS8625 }