/* ======================================================================== * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua.Bindings.Pcap.Models; namespace Opc.Ua.Bindings.Pcap.Capture { /// /// Wraps an with a state machine, a /// per-session async lock, and lifecycle metadata. The lifecycle is: /// Starting → Running → Stopping → (Completed | Failed) → Disposed. /// /// /// Sealed by repository convention. Sessions are created and owned by /// ; consumers should obtain /// references through that manager rather than constructing sessions /// directly. /// public sealed class CaptureSession : IAsyncDisposable { private readonly SemaphoreSlim m_lock = new(1, 1); private readonly ILogger m_logger; /// /// Constructs a new capture session wrapping the given source. /// /// /// Any argument is null. /// public CaptureSession( string id, CaptureSourceKind sourceKind, ICaptureSource source, string sessionFolder, StartCaptureRequest request, ILogger? logger = null) { ArgumentException.ThrowIfNullOrWhiteSpace(id); ArgumentNullException.ThrowIfNull(source); ArgumentException.ThrowIfNullOrWhiteSpace(sessionFolder); ArgumentNullException.ThrowIfNull(request); Id = id; SourceKind = sourceKind; Source = source; SessionFolder = sessionFolder; Request = request; m_logger = logger ?? NullLogger.Instance; } /// /// Session id (unique within a session manager). /// public string Id { get; } /// /// The kind of source. /// public CaptureSourceKind SourceKind { get; } /// /// The underlying capture source. /// public ICaptureSource Source { get; } /// /// The folder under which session artifacts are written. /// public string SessionFolder { get; } /// /// The request that started this session. /// public StartCaptureRequest Request { get; } /// /// Current state. Mutated only by Start/Stop/Dispose. /// public CaptureSessionState State { get; private set; } = CaptureSessionState.Starting; /// /// UTC timestamp the session entered Running. /// public DateTimeOffset? StartedAt { get; private set; } /// /// UTC timestamp the session stopped. /// public DateTimeOffset? StoppedAt { get; private set; } /// /// Failure message (if state is Failed). /// public string? Error { get; private set; } /// /// Acquires the per-session async lock. The returned token must /// be disposed by the caller (use await using). /// public async ValueTask AcquireAsync(CancellationToken ct) { await m_lock.WaitAsync(ct).ConfigureAwait(false); return new SessionLockReleaser(m_lock); } /// /// Starts the session. Idempotent if already running. /// /// public async ValueTask StartAsync(CancellationToken ct) { IAsyncDisposable sessionLock = await AcquireAsync(ct).ConfigureAwait(false); try { if (State == CaptureSessionState.Running) { return; } if (State != CaptureSessionState.Starting) { throw new PcapDiagnosticsException( $"Session {Id} cannot be started from state {State}."); } try { await Source.StartAsync(Request, ct).ConfigureAwait(false); StartedAt = DateTimeOffset.UtcNow; State = CaptureSessionState.Running; m_logger.LogInformation( "Capture session {SessionId} started ({Source}).", Id, SourceKind); } catch (Exception ex) when (ex is not OperationCanceledException) { Error = ex.Message; State = CaptureSessionState.Failed; m_logger.LogError( ex, "Capture session {SessionId} failed to start.", Id); throw; } } finally { await sessionLock.DisposeAsync().ConfigureAwait(false); } } /// /// Stops the session. Idempotent if already stopped. /// public async ValueTask StopAsync(CancellationToken ct) { IAsyncDisposable sessionLock = await AcquireAsync(ct).ConfigureAwait(false); try { if (State is CaptureSessionState.Completed or CaptureSessionState.Failed or CaptureSessionState.Disposed) { return; } State = CaptureSessionState.Stopping; try { await Source.StopAsync(ct).ConfigureAwait(false); StoppedAt = DateTimeOffset.UtcNow; State = CaptureSessionState.Completed; m_logger.LogInformation( "Capture session {SessionId} stopped " + "({FrameCount} frames, {ByteCount} bytes).", Id, Source.FrameCount, Source.ByteCount); } catch (Exception ex) when (ex is not OperationCanceledException) { Error = ex.Message; State = CaptureSessionState.Failed; m_logger.LogError( ex, "Capture session {SessionId} failed to stop.", Id); throw; } } finally { await sessionLock.DisposeAsync().ConfigureAwait(false); } } /// /// Projects the session state into a public DTO suitable for MCP. /// public CaptureSessionInfo ToInfo() { return new CaptureSessionInfo { SessionId = Id, Source = SourceKind, State = State, StartedAt = StartedAt, StoppedAt = StoppedAt, FrameCount = Source.FrameCount, ByteCount = Source.ByteCount, SessionFolder = SessionFolder, PcapFilePath = Source.GetRawPcapFilePath(), KeyLogFilePath = Source.GetKeyLogFilePath(), Error = Error }; } /// public async ValueTask DisposeAsync() { try { if (State is CaptureSessionState.Running or CaptureSessionState.Starting) { await StopAsync(CancellationToken.None).ConfigureAwait(false); } } catch { // Best effort on dispose. } await Source.DisposeAsync().ConfigureAwait(false); m_lock.Dispose(); State = CaptureSessionState.Disposed; } private sealed class SessionLockReleaser : IAsyncDisposable { private readonly SemaphoreSlim m_semaphore; private int m_released; public SessionLockReleaser(SemaphoreSlim semaphore) { m_semaphore = semaphore; } public ValueTask DisposeAsync() { if (Interlocked.Exchange(ref m_released, 1) == 0) { m_semaphore.Release(); } return default; } } } }