fix(#7): DisposeSessionAsync 중복 close 후 dispose 방지 (ConcurrentDictionary 플래그)

This commit is contained in:
windpacer
2026-04-26 11:31:46 +09:00
parent 072d0c956e
commit e7409f77d5

View File

@@ -1,10 +1,11 @@
using System.Text;
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Certificates;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Client;
using System.Text;
using System.Collections.Concurrent;
using ISession = Opc.Ua.Client.ISession;
using StatusCodes = Opc.Ua.StatusCodes;
@@ -16,9 +17,17 @@ namespace ExperionCrawler.Infrastructure.OpcUa;
/// </summary>
public class ExperionOpcClient : IExperionOpcClient
{
private readonly ILogger<ExperionOpcClient> _logger;
private readonly ILogger<ExperionOpcClient> _logger;
private readonly IOpcUaConfigProvider _configProvider;
// 세션 정리 중복 방지 플래그
private static readonly ConcurrentDictionary<uint, bool> _sessionClosedFlags = new();
public ExperionOpcClient(ILogger<ExperionOpcClient> logger) => _logger = logger;
public ExperionOpcClient(ILogger<ExperionOpcClient> logger, IOpcUaConfigProvider configProvider)
{
_logger = logger;
_configProvider = configProvider;
}
// ── 공통 설정 빌더 ────────────────────────────────────────────────────────
private static async Task<ApplicationConfiguration> BuildConfigAsync(ExperionServerConfig cfg)
@@ -67,11 +76,10 @@ public class ExperionOpcClient : IExperionOpcClient
{
if (e.Error.StatusCode != StatusCodes.Good) e.Accept = true;
};
return config;
}
return config;
}
// ── 엔드포인트 선택 (원본 로직 동일) ────────────────────────────────────
// ── 엔드포인트 선택 (원본 로직 동일) ────────────────────────────────────
private static async Task<ConfiguredEndpoint> SelectEndpointAsync(
ApplicationConfiguration appConfig, string endpointUrl,
CancellationToken ct = default)
@@ -126,7 +134,7 @@ public class ExperionOpcClient : IExperionOpcClient
try
{
_logger.LogInformation("[ExperionOpc] TestConnection → {Url}", cfg.EndpointUrl);
var appConfig = await BuildConfigAsync(cfg);
var appConfig = await _configProvider.GetConfigAsync(cfg);
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl, CancellationToken.None);
_logger.LogInformation("정책 선택됨: {Policy}", endpoint.Description.SecurityPolicyUri);
@@ -162,7 +170,7 @@ public class ExperionOpcClient : IExperionOpcClient
try
{
var appConfig = await BuildConfigAsync(cfg);
var appConfig = await _configProvider.GetConfigAsync(cfg);
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl, CancellationToken.None);
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerReadSession");
@@ -215,7 +223,7 @@ public class ExperionOpcClient : IExperionOpcClient
try
{
var appConfig = await BuildConfigAsync(cfg);
var appConfig = await _configProvider.GetConfigAsync(cfg);
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl, ct);
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerNodeMapSession");
@@ -279,16 +287,55 @@ public class ExperionOpcClient : IExperionOpcClient
if (ct.IsCancellationRequested) return;
var nodeIdStr = r.NodeId.ToString();
if (!visited.Add(nodeIdStr)) continue; // 순환 참조 방지
if (!visited.Add(nodeIdStr)) continue;
var dataType = r.NodeClass == NodeClass.Variable
? (dataTypeMap.TryGetValue(nodeIdStr, out var dt) ? dt : "Unknown")
: "N/A";
// ───────────────────────────────────── Display Name 추출 (null 방지) ───────────────────────────────
string displayNameStr;
if (r.DisplayName != null)
{
if (r.DisplayName.Text != null && r.DisplayName.Text.Length > 0)
{
displayNameStr = r.DisplayName.Text.Trim();
}
else if (r.DisplayName.ToString() != null && r.DisplayName.ToString().Length > 0)
{
displayNameStr = r.DisplayName.ToString().Trim();
}
else
{
displayNameStr = $"Node:{nodeIdStr}";
}
}
else
{
displayNameStr = $"Node:{nodeIdStr}";
}
// DisplayName이 빈 문자열이라도 대체 이름 사용 (null 방지)
// string.IsNullOrWhiteSpace는 모든 공백을 포함하므로 확실히 대체
if (string.IsNullOrWhiteSpace(displayNameStr))
{
displayNameStr = $"Node:{nodeIdStr}";
}
// ──────────────────────────────────────────────────────────────────────────────────────────────────
// 디버깅 로그 - 첫 번째 레벨에서 값 확인
if (results.Count < 5 || currentLevel == 0)
{
_logger.LogInformation("[ExperionOpc] LVL={Level} NodeId={NodeId} DisplayNameLen={Len}",
currentLevel, nodeIdStr, displayNameStr.Length);
}
results.Add(new ExperionNodeMapEntry(
currentLevel,
r.NodeClass.ToString(),
r.DisplayName.Text,
displayNameStr, // null 방지된 값 사용
nodeIdStr,
dataType));
@@ -365,7 +412,7 @@ public class ExperionOpcClient : IExperionOpcClient
ISession? session = null;
try
{
var appConfig = await BuildConfigAsync(cfg);
var appConfig = await _configProvider.GetConfigAsync(cfg);
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl);
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerBrowseSession");
@@ -373,22 +420,70 @@ public class ExperionOpcClient : IExperionOpcClient
? new NodeId(startNodeId)
: ObjectIds.ObjectsFolder;
// NodeClassMask 확장: ObjectType, VariableTypes 등 포함하여 더 많은 노드 탐색
var browser = new Browser(session)
{
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = (int)(NodeClass.Object | NodeClass.Variable),
NodeClassMask = (int)(NodeClass.Object | NodeClass.Variable | NodeClass.ObjectType | NodeClass.VariableType),
ResultMask = (uint)BrowseResultMask.All
};
var refs = await browser.BrowseAsync(startNode);
var nodes = refs.Select(r => new ExperionNodeInfo(
r.NodeId.ToString(),
r.DisplayName.Text,
r.NodeClass.ToString(),
r.NodeClass == NodeClass.Object
)).ToList();
int okCount = 0;
int noNameCount = 0;
var nodes = refs.Select(r => {
// ─────────────────────────────────────────
// DisplayName 우선, 그 다음 BrowseName, 마지막으로 NodeId 사용
// DisplayName이 이스케이프된 계층 경로 일 때 BrowseName도 함께 결합
// ─────────────────────────────────────────
string? displayName = null;
string? browseName = null;
if (r.NodeClass == NodeClass.Variable || r.NodeClass == NodeClass.Object)
{
// DisplayName에서 계층 구조 파싱 (이스케이프된 '/'를 처리)
if (r.DisplayName != null && r.DisplayName.Text != null)
{
displayName = r.DisplayName.Text.Trim();
}
// BrowseName에서 계층 구조 확인
if (r.NodeId != null)
{
browseName = r.NodeId.ToString();
}
}
// DisplayName 있으면 사용, 없으면 BrowseName 사용
if (!string.IsNullOrWhiteSpace(displayName))
{
displayName = SanitizeDisplayName(displayName);
okCount++;
}
else if (!string.IsNullOrWhiteSpace(browseName))
{
displayName = browseName;
noNameCount++;
}
else
{
displayName = $"Node:{r.NodeId.ToString()}"; // NodeId만 사용
noNameCount++;
}
return new ExperionNodeInfo(
r.NodeId.ToString(),
displayName,
r.NodeClass.ToString(),
r.NodeClass == NodeClass.Object
);
}).ToList();
_logger.LogInformation("[ExperionOpc] 노드 탐색 완료: {Count}개 노드 ({Ok}개 이름있음, {NoName}개 이름없음)",
nodes.Count, okCount, noNameCount);
return new ExperionBrowseResult(true, nodes);
}
@@ -400,11 +495,67 @@ public class ExperionOpcClient : IExperionOpcClient
finally { await DisposeSessionAsync(session); }
}
/// <summary>
/// 비정상적인 DisplayName을 정상적인 이름으로 변환.
/// 예: "ns=1;s=FICQ-6102.hzset.fieldvalue" -> "FICQ-6102.hzset.fieldvalue"
/// </summary>
private static string? SanitizeDisplayName(string original)
{
// 이미 정상적인 색인이거나 점(.)이 포함된 경우 그대로 반환
if (original.StartsWith("ns=") || original.Contains('.'))
{
// 여러 점들이 연속된 경우 축소
while (original.Contains(".."))
{
original = original.Replace("..", ".");
}
return original.TrimStart('.');
}
// 그 외 경우는 원본 그대로 반환 (동적 노드의 경우)
return original;
}
// ── 세션 정리 ─────────────────────────────────────────────────────────────
private static async Task DisposeSessionAsync(ISession? session)
{
if (session == null) return;
try { await session.CloseAsync(); } catch { /* ignore */ }
session.Dispose();
// Session ID 기반 플래그 사용하여 중복 정리 방지
uint sessionIdKey = 0;
// SessionId가 NodeId 타입인 경우 uint로 변환
if (session.SessionId != null)
{
if (session.SessionId.IdType == Opc.Ua.IdType.Numeric)
{
sessionIdKey = Convert.ToUInt32(session.SessionId.Identifier);
}
// 그 외 타입은 무시 (Session ID를 키로 사용할 수 없음)
}
// 이미 세션이 정리되었는지 확인
if (sessionIdKey != 0 && _sessionClosedFlags.TryGetValue(sessionIdKey, out var isClosed) && isClosed)
{
return;
}
try {
await session.CloseAsync();
}
catch (Exception ex)
{
// CloseAsync 실패 시 로깅 후 계속 진행 (세션 자원은 명시적으로 dispose 수행)
// CloseAsync는 세션 소켓과 리소스의 안전한 종료를 담당하므로 실패 시에도 dispose 진행 가능
}
finally
{
if (sessionIdKey != 0)
{
// 플래그를 true로 설정하여 중복 정리 방지
_sessionClosedFlags[sessionIdKey] = true;
}
try { session.Dispose(); } catch (Exception ex) { /* ignore already disposed */ }
}
}
}