From 81e2ea145ad8855e0b4f221724934b4525600078 Mon Sep 17 00:00:00 2001 From: windpacer Date: Mon, 25 May 2026 17:31:23 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20realtime=20supervisor=20=E2=80=94=20OPC?= =?UTF-8?q?=20=EC=9E=A5=EA=B8=B0=EB=8B=A8=EC=A0=88=20=ED=9B=84=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EB=B3=B5=EA=B5=AC=20(OCE=20=EC=98=A4=EC=9D=B8?= =?UTF-8?q?=EC=A4=91=EC=A7=80=20+=20=EC=97=B0=EA=B2=B0=20=ED=83=80?= =?UTF-8?q?=EC=9E=84=EC=95=84=EC=9B=83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SuperviseAsync: catch(OperationCanceledException){break} 가 OPC SDK 연결 타임아웃 (TaskCanceledException=OCE 하위)을 '우리 취소'로 오인해 '중지됨'으로 영구정지되던 버그 수정 → when(ct.IsCancellationRequested) 가드, 그 외 OCE는 일반 오류처럼 재시도 - CreateSessionAsync: CancellationToken.None → 실제 토큰 전달 (죽은 서버 상대 세션생성이 SDK 기본 OperationTimeout ~2분 블로킹하던 원인 제거) - 1회 연결 시도 10초 타임아웃(연결토큰 + WaitAsync 하드 백스톱) - 재시도 주기 30s → 10s (리던던트 서버 전환 시 수분 단절도 빠르게 재포착) - 라이브 검증: Experion kill→2.5분→revive 시 재연결 순환 유지 후 수동개입 0 자동 구독복구 Co-Authored-By: Claude Opus 4.7 --- .../OpcUa/ExperionRealtimeService.cs | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) 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 }