Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 117 additions & 7 deletions src/AR.Iec61850.Transports.Npcap/NpcapProcessBusDuplexTransport.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Buffers.Binary;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using AR.Iec61850.Transports;
Expand All @@ -11,9 +13,16 @@ namespace AR.Iec61850.Transports.Npcap;
/// </summary>
public sealed class NpcapProcessBusDuplexTransport : IProcessBusTransport, IProcessBusFrameSource, IDisposable
{
private const ushort VlanEtherType = 0x8100;
private const ushort SampledValuesEtherType = 0x88BA;
private static readonly long MinimumLearnableIntervalTicks = Math.Max(1, Stopwatch.Frequency / 50_000); // 20 us
private static readonly long MaximumLearnableIntervalTicks = Math.Max(1, Stopwatch.Frequency / 200); // 5 ms

private readonly ICaptureDevice _device;
private readonly IInjectionDevice _injectionDevice;
private readonly object _gate = new();
private readonly SemaphoreSlim _sendGate = new(1, 1);
private readonly Dictionary<SvTransmitKey, SvTransmitClock> _svTransmitClocks = new();
private bool _capturing;
private bool _disposed;

Expand All @@ -31,12 +40,27 @@ public NpcapProcessBusDuplexTransport(ICaptureDevice device)
_device.Open(DeviceModes.Promiscuous, 1000);
}

public ValueTask SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken = default)
public async ValueTask SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken = default)
{
ObjectDisposedException.ThrowIf(_disposed, this);
cancellationToken.ThrowIfCancellationRequested();
_injectionDevice.SendPacket(frame.ToArray());
return ValueTask.CompletedTask;

await _sendGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var isSampledValues = TryReadSampledValuesKey(frame.Span, out var streamKey);
if (isSampledValues)
await PaceSampledValuesAsync(streamKey, cancellationToken).ConfigureAwait(false);
Comment on lines +51 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep SV pacing from blocking unrelated traffic

When SV and PTP or GOOSE share this duplex transport, an SV call acquires the global send gate before awaiting its pacing deadline, so every unrelated frame is blocked for the remaining learned interval, potentially up to 5 ms. The inspected SV Publisher workflow does share this transport with the concurrent lab PTP publisher and peer-delay responder, meaning time-sensitive PTP responses can be delayed even though they are supposed to remain unpaced; pacing state needs separate synchronization from the short device-injection critical section.

AGENTS.md reference: AGENTS.md:L173-L178

Useful? React with 👍 / 👎.


_injectionDevice.SendPacket(frame.ToArray());

if (isSampledValues)
CommitSampledValuesSend(streamKey, Stopwatch.GetTimestamp());
}
finally
{
_sendGate.Release();
}
}

public async IAsyncEnumerable<ProcessBusCapturedFrame> CaptureAsync(
Expand Down Expand Up @@ -72,22 +96,22 @@ public async IAsyncEnumerable<ProcessBusCapturedFrame> CaptureAsync(

handler = (_, capture) =>
{
var frame = new ProcessBusCapturedFrame
var capturedFrame = new ProcessBusCapturedFrame
{
Timestamp = ToDateTimeOffset(capture.Header.Timeval),
Frame = capture.Data.ToArray(),
Source = _device.Name ?? string.Empty
};

channel.Writer.TryWrite(frame);
channel.Writer.TryWrite(capturedFrame);
};

_device.OnPacketArrival += handler;
_device.StartCapture();
started = true;

await foreach (var frame in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
yield return frame;
await foreach (var capturedFrame in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
yield return capturedFrame;
}
finally
{
Expand Down Expand Up @@ -127,13 +151,99 @@ public void Dispose()
// Best-effort cleanup only.
}

_sendGate.Dispose();
_disposed = true;
}

private async ValueTask PaceSampledValuesAsync(SvTransmitKey key, CancellationToken cancellationToken)
{
if (!_svTransmitClocks.TryGetValue(key, out var clock) || clock.NominalIntervalTicks <= 0)
return;

var targetTicks = clock.LastSentTicks + clock.NominalIntervalTicks;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var remainingTicks = targetTicks - Stopwatch.GetTimestamp();
if (remainingTicks <= 0)
return;

var remainingMilliseconds = remainingTicks * 1000.0 / Stopwatch.Frequency;
if (remainingMilliseconds > 2)
{
await Task.Delay(
TimeSpan.FromMilliseconds(Math.Min(remainingMilliseconds - 1, 10)),
cancellationToken)
.ConfigureAwait(false);
}
else
{
Thread.SpinWait(64);
}
}
}

private void CommitSampledValuesSend(SvTransmitKey key, long sentTicks)
{
if (!_svTransmitClocks.TryGetValue(key, out var clock))
{
_svTransmitClocks[key] = new SvTransmitClock(sentTicks, 0);
return;
}

var observedInterval = sentTicks - clock.LastSentTicks;
var nominalInterval = clock.NominalIntervalTicks;
var learnable = observedInterval >= MinimumLearnableIntervalTicks &&
observedInterval <= MaximumLearnableIntervalTicks &&
(nominalInterval <= 0 || observedInterval <= nominalInterval * 3);
Comment on lines +196 to +198

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid learning the pacing rate from a late interval

When the thread is delayed between the first two frames, any gap from 20 µs through 5 ms is accepted as the nominal interval, even though it may be scheduler lateness rather than the requested rate. For example, a 4,000 fps publisher preempted for 1 ms after its first send learns 1 ms; subsequent pacing then enforces that interval, and because later observations are taken after pacing, the clock cannot converge back to 250 µs. This permanently reduces the wire rate while smpCnt and reference timestamps continue to represent the configured rate. Seed or update the estimate only from demonstrated steady intervals rather than accepting the first in-range observation.

AGENTS.md reference: AGENTS.md:L173-L178

Useful? React with 👍 / 👎.


if (learnable)
{
nominalInterval = nominalInterval <= 0
? observedInterval
: (long)Math.Round((nominalInterval * 0.9) + (observedInterval * 0.1));
}

_svTransmitClocks[key] = new SvTransmitClock(sentTicks, nominalInterval);
}

private static bool TryReadSampledValuesKey(ReadOnlySpan<byte> frame, out SvTransmitKey key)
{
key = default;
if (frame.Length < 22)
return false;

var etherType = BinaryPrimitives.ReadUInt16BigEndian(frame.Slice(12, 2));
var processBusOffset = 14;
ushort vlanId = 0;
if (etherType == VlanEtherType)
{
if (frame.Length < 26)
return false;

vlanId = (ushort)(BinaryPrimitives.ReadUInt16BigEndian(frame.Slice(14, 2)) & 0x0FFF);
etherType = BinaryPrimitives.ReadUInt16BigEndian(frame.Slice(16, 2));
processBusOffset = 18;
}

if (etherType != SampledValuesEtherType || frame.Length < processBusOffset + 2)
return false;

key = new SvTransmitKey(
BinaryPrimitives.ReadUInt64BigEndian(frame.Slice(0, 8)),
BinaryPrimitives.ReadUInt32BigEndian(frame.Slice(8, 4)),
BinaryPrimitives.ReadUInt16BigEndian(frame.Slice(processBusOffset, 2)),
vlanId);
return true;
}

private static DateTimeOffset ToDateTimeOffset(PosixTimeval timeval)
{
var seconds = Convert.ToInt64(timeval.Seconds);
var microseconds = Convert.ToInt64(timeval.MicroSeconds);
return DateTimeOffset.FromUnixTimeSeconds(seconds).AddTicks(checked(microseconds * 10));
}

private readonly record struct SvTransmitKey(ulong MacPrefix, uint MacSuffix, ushort AppId, ushort VlanId);
private sealed record SvTransmitClock(long LastSentTicks, long NominalIntervalTicks);
}
Loading