diff --git a/Directory.Build.props b/Directory.Build.props
index 948061c..f2f2e8c 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -7,7 +7,7 @@
내지 않는다 — 다음 판에 같이 나간다). 올린 번호는
다시 쓰지 않는다 — NuGet 은 지울 수도 덮어쓸 수도 없다. publish.yml 이 태그와 이 값이 같은지, 태그 커밋이
main 에 있는지 검사하고, 태그가 아닌 ref 에서는 올리지 않는다. -->
- 0.4.1
+ 0.5.0
kintaein
Apache-2.0
diff --git a/docs/architecture.md b/docs/architecture.md
index 6b74200..1e428e3 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -105,7 +105,9 @@ That is a deliberate resting state, not a silent failure.
public sealed class GevDiscoveryOpt
{
public int TimeoutMs { get; set; } = 1000; // collect replies for this long
- public int Repeat { get; set; } = 2; // total DISCOVERY_CMD sends per target within the window, including the first (not "retries")
+ public int Repeat { get; set; } = 2; // total DISCOVERY_CMD sends per target within the window, including the first (not "retries"); < 1 → ArgumentOutOfRangeException
+ // sends are scheduled from the window start every min(200, TimeoutMs / Repeat) ms (1 ms floor); a send due
+ // at or after the window end is dropped, so Repeat never stretches the window (Repeat > TimeoutMs → TimeoutMs sends)
public IReadOnlyList? Interfaces { get; set; } // null = every IPv4 interface that is up
public bool LimitedBroadcast { get; set; } = true; // 255.255.255.255
public bool DirectedBroadcast { get; set; } = true; // subnet broadcast of each interface
@@ -114,7 +116,9 @@ public sealed class GevDiscoveryOpt
public static class GevDiscovery
{
public static Task> DiscoverAsync(GevDiscoveryOpt? opt = null, CancellationToken ct = default);
- /// unicast DISCOVERY_CMD to one address (works across subnets and on loopback simulators)
+ /// unicast DISCOVERY_CMD to one address (works across subnets and on loopback simulators), sent once without retry.
+ /// null = no usable reply, not "no device": no reply in time (Debug), an error status (Warn) or a reply shorter than
+ /// the 248-byte block (Warn) — the same replies DiscoverAsync skips. Throws for bad arguments, cancellation and socket failures.
public static Task ProbeAsync(IPAddress address, int timeoutMs = 1000, CancellationToken ct = default);
public static Task ForceIpAsync(PhysicalAddress mac, IPAddress ip, IPAddress subnet, IPAddress gateway, GevDiscoveryOpt? opt = null, CancellationToken ct = default);
}
@@ -146,6 +150,11 @@ Discovery details: one socket per interface bound to `(interfaceIp, 0)` with `En
255.255.255.255:3956 and to the interface's directed broadcast; collect ACKs until the timeout; dedupe by
MAC (keep the reply whose interface shares the device subnet if there are several). Truncated ACKs are
logged and skipped, never turned into ghost entries. Flags byte = `FlagAckRequired | FlagAllowBroadcastAck`.
+An empty list is not only "nobody answered": with no interface to send on (`Interfaces` is an empty list, no
+non-loopback IPv4 interface is up — an unplugged or disabled NIC is not — or the interface list cannot be read)
+`DiscoverAsync` returns at once without waiting for the window, and when interfaces exist but no DISCOVERY_CMD
+left any of them (bind failure, no target, every send failed) the result is empty as well. Both cases log a Warn
+naming the cause; the return type stays a list rather than an exception.
### Device
@@ -158,27 +167,32 @@ public sealed class GevDeviceOpt
public int GvcpTimeoutMs { get; set; } = 500;
public int GvcpRetries { get; set; } = 3;
public int HeartbeatTimeoutMs { get; set; } = 3000; // written to GVBS 0x0938 when we control
- public int? HeartbeatPeriodMs { get; set; } // null = device-accepted timeout / 3; ReadOnly sessions run no heartbeat (GevDevice.HeartbeatPeriodMs = 0)
+ public int? HeartbeatPeriodMs { get; set; } // null = device-accepted timeout / 3 (HeartbeatTimeoutMs / 3 when the device reads back 0 or a value beyond int range); ReadOnly sessions run no heartbeat (GevDevice.HeartbeatPeriodMs = 0)
public IPAddress? LocalAddress { get; set; } // null = auto (route lookup / discovery interface)
public string? XmlCacheDir { get; set; } // null = no on-disk cache of the camera XML
public bool AllowSwitchover { get; set; } = false; // set CCP switchover-enable bit
public int? MaxPendingAckWaitMs { get; set; } // null = derived so a PENDING_ACK cannot hold the GVCP queue
- // past the device heartbeat timeout; setting a value turns that derivation off
+ // past the device heartbeat timeout; setting a value turns that derivation off.
+ // 0 = no extension (not "no cap"): a PENDING_ACK'd command must finish within one
+ // GvcpTimeoutMs, else GevTimeoutException without a resend, whatever GvcpRetries says
}
public sealed class GevDevice : IGevPort, IAsyncDisposable
{
public static Task OpenAsync(GevDeviceInfo info, GevDeviceOpt? opt = null, CancellationToken ct = default);
public static Task OpenAsync(IPAddress address, GevDeviceOpt? opt = null, CancellationToken ct = default);
+ public static Task OpenAsync(IPEndPoint device, GevDeviceOpt? opt = null, CancellationToken ct = default);
+ // non-standard GVCP port (simulator on loopback, NAT/port forwarding);
+ // the port is used for the control channel only; port 0 → ArgumentOutOfRangeException
public GevDeviceInfo Info { get; } // re-read from bootstrap registers after open
public IPAddress Address { get; }
public IPAddress LocalAddress { get; } // host address used for GVCP; also the SCDA for streams
public GevAccessMode AccessMode { get; }
- public bool IsOpen { get; }
+ public bool IsOpen { get; } // false after DisposeAsync or once control is lost (see "Errors" below)
public uint GvcpCapability { get; } // GVBS 0x0934
public ulong TimestampTickFrequency { get; } // GVBS 0x093C/0x0940 (0 if unreadable)
- public int DeviceHeartbeatTimeoutMs { get; } // GVBS 0x0938 read back after we wrote it
+ public int DeviceHeartbeatTimeoutMs { get; } // GVBS 0x0938 read back after we wrote it; a uint register, saturated at int.MaxValue (never negative)
public int HeartbeatPeriodMs { get; } // 0 for a read-only session (no heartbeat runs)
public event Action? ControlLost; // heartbeat failed or CCP taken by someone else
@@ -192,7 +206,8 @@ public sealed class GevDevice : IGevPort, IAsyncDisposable
public Task GetXmlAsync(CancellationToken ct = default); // Xml module
public Task GetNodeMapAsync(CancellationToken ct = default); // cached after first call
- public Task SetTlParamsLockedAsync(bool locked, CancellationToken ct = default); // host-side node, not a register; gates the acquisition commands
+ public Task SetTlParamsLockedAsync(bool locked, CancellationToken ct = default); // host-side node, not a register; gates the acquisition commands.
+ // false = nothing written: no TLParamsLocked (Debug log), or one that is not an integer node (Warn log)
public Task OpenStreamAsync(GevStreamOpt? opt = null, CancellationToken ct = default); // Gvsp module; channel 0
public Task OpenStreamAsync(int streamChannel, GevStreamOpt? opt = null, CancellationToken ct = default); // channel count from GVBS 0x0904
// Both overloads need control: a ReadOnly session cannot write the stream-channel registers and is
@@ -217,6 +232,19 @@ seen. A cancelled or timed-out open may already have been applied by the device,
locks the camera for a whole device heartbeat timeout (R21). The only case that must not release is
`ACCESS_DENIED`, where the privilege belongs to another application.
+Errors. Device operations throw the `GevException` family (`GevTimeoutException` no reply,
+`GevStatusException` device refusal, `GevControlLostException` control lost) **and `ObjectDisposedException`,
+which is not a `GevException`**: every device access after `DisposeAsync` throws it — node operations of a
+node map taken earlier included, since the device is their port (`GetXmlAsync`/`GetNodeMapAsync` alone answer
+from the session cache) — and so does a request that reaches the channel after it closed while racing
+`DisposeAsync`. Cancellation is `OperationCanceledException`. A caller that means "any library failure"
+catches `GevException` and `ObjectDisposedException` together. If the control channel closes underneath an
+open session — its receive socket failed beyond recovery, or someone disposed `device.Gvcp` — the session
+turns control-lost on the spot: `IsOpen` becomes false, `ControlLost` fires, and later calls throw
+`GevControlLostException`, rather than answering "open" while every call fails with `ObjectDisposedException`
+until three heartbeats have failed (a read-only session, which runs no heartbeat, never left that state).
+Pinned by `DeviceLifecycleTests.Dispose_NodeMapTakenBefore_*` and `GvcpChannelClosedUnderAnOpenDevice_*`.
+
`IGevPort` implementation: `ReadAsync`/`WriteAsync` map to READMEM/WRITEMEM; 4-byte-aligned 4-byte
accesses may use READREG/WRITEREG. An address above `uint.MaxValue` is narrowed to its low 32 bits with a
one-time warning per address — vendor descriptions declare such addresses (see `docs/protocol-notes.md`) and
@@ -237,7 +265,8 @@ public sealed class GvcpChannel : IDisposable
/// req_id and the expected ACK command; PENDING_ACK extends the wait to the announced time plus one more
/// TimeoutMs window (capped by MaxPendingAckWaitMs) — ending exactly at the announced time turns a reply that is
/// late by the timer granularity into a timeout, and the retry makes the device execute the command twice;
- /// retries on timeout.
+ /// retries on timeout, except once a PENDING_ACK was seen: the device has taken the command, so it is not resent
+ /// and GevTimeoutException is thrown (a caller that resends it may run it twice).
public Task RequestAsync(GvcpCmd cmd, CancellationToken ct = default);
/// fire-and-forget command with ack_required = 0 (PACKETRESEND). Thread-safe, no allocation on the hot path.
public void SendNoAck(ReadOnlySpan packet);
@@ -267,11 +296,16 @@ public sealed class GevStreamOpt
public PacketSizeMode PacketSizeMode { get; set; } = PacketSizeMode.Auto; // Auto: probe with SCPS fire-test from the NIC MTU downwards
public int PacketSize { get; set; } = 1500; // used when Fixed; Auto stores the negotiated value here after StartAsync
public int SocketBufferBytes { get; set; } = 32 * 1024 * 1024;
- public bool ResendEnabled { get; set; } = true;
+ public bool ResendEnabled { get; set; } = true; // false (or PacketRequestRatio = 0): no requests, FrameRetentionMs unused — see below
public int InitialPacketTimeoutMs { get; set; } = 2; // wait before the first resend request (reordering grace)
- public int PacketTimeoutMs { get; set; } = 20; // between resend requests for the same hole
- public int FrameRetentionMs { get; set; } = 100; // give up on a frame this long after its last packet
- public double PacketRequestRatio { get; set; } = 0.25; // never request more than this fraction of a frame's DISTINCT packets; asking for the same hole again does not spend more budget
+ public int PacketTimeoutMs { get; set; } = 20; // between resend requests for the same hole; also the silence that marks a frame's tail as sent,
+ // and the give-up time when there is nothing left to request (resend off, budget spent, device refused)
+ public int FrameRetentionMs { get; set; } = 100; // give up on a frame this long after its last packet — only while resend is on and still asking,
+ // or (resend on) for a frame already dropped for another reason whose trailer never came;
+ // with resend off an incomplete frame goes after PacketTimeoutMs, or at once when a newer block starts,
+ // and a dropped frame without its trailer goes after PacketTimeoutMs
+ public double PacketRequestRatio { get; set; } = 0.25; // never request more than this fraction of a frame's DISTINCT packets; asking for the same hole again does not spend more budget.
+ // 0 turns resend off (same as ResendEnabled = false); any value above 0 still allows at least one request
public bool DeliverIncompleteFrames { get; set; } = false;
public bool FirewallTraversal { get; set; } = true; // one byte to the device's SCSP port after opening the channel
public int FirewallTraversalIntervalMs { get; set; } = 15_000; // re-send that byte after this much silence (0 = never)
@@ -289,7 +323,10 @@ public sealed class GevStream : IAsyncDisposable
public int LocalPort { get; }
public int PacketSize { get; } // negotiated SCPS
public GevStreamStats Stats { get; } // live counters (Interlocked), snapshot via Stats.Snapshot()
- public bool IsStarted { get; } // true between a successful StartAsync and StopAsync
+ public bool IsStarted { get; } // true from a successful StartAsync until StopAsync/DisposeAsync, or until the
+ // receiver thread ends on its own (stream socket died) — then ReceiveAsync hands
+ // out what is queued and throws GevStreamClosedException; the stream cannot be
+ // restarted, and StopAsync is still needed to turn the device off and return buffers
public event Action? FrameDropped; // Reason is one of four: Incomplete / NoBuffer / Error / Unsupported (called on receiver thread — keep it cheap)
public Task StartAsync(CancellationToken ct = default); // bind + tune socket, write SCDA/SCP, negotiate SCPS, apply SCPD, start thread. Does NOT send AcquisitionStart.
@@ -300,7 +337,13 @@ public sealed class GevStream : IAsyncDisposable
public Task StopAsync(CancellationToken ct = default); // SCP = 0, SCDA = 0, close the socket to wake the thread, join it,
// then **drain the queue and Dispose every frame still in it** — skipping that
// leaves those pool buffers held forever — and complete pending receives
- // with GevStreamClosedException
+ // with GevStreamClosedException. The token aborts no step: a cancelled (even
+ // pre-cancelled) token still turns the device off, runs the whole local
+ // cleanup and returns normally — a half-stopped stream is worse than either.
+ // The two writes share one fixed budget of their own (2 × GvcpTimeoutMs, at most
+ // 2 s) that depends on neither the token nor GvcpRetries, so a device that stopped
+ // answering costs at most that (then a Warn, and local cleanup goes on); the join
+ // is capped at 2 s. A failed start resets SCP within the same budget
public ValueTask ReceiveAsync(CancellationToken ct = default); // waits until a frame, the token, or StopAsync/DisposeAsync —
// NOT until the device goes away (see "Stream lifetime" below)
public bool TryReceive(out GevFrame? frame);
@@ -342,9 +385,13 @@ with the ordinary success status instead — one measured camera does (`docs/eva
```
```
-Receiver design: one dedicated background thread per stream. Blocking `Receive` (not `ReceiveFrom`: no
-per-packet `EndPoint` allocation; the deliberate consequence is that the datagram source is not checked
-against the device address — any host that can reach the bound UDP port feeds the reassembler) into a
+Receiver design: one dedicated background thread per stream. Non-blocking `Receive` while datagrams are
+queued, and `Poll` to wait (2 ms-class interval while a frame is being assembled, 200 ms idle) only when the
+socket is empty — never a socket receive timeout, because a timed-out blocking receive on Windows can lose
+the datagram arriving at that moment (measured: `docs/evaluation.md`, "Receive wait on Windows"). `Receive`,
+not `ReceiveFrom`: no per-packet `EndPoint` allocation; the deliberate consequence is that the datagram
+source is not checked against the device address — any host that can reach the bound UDP port feeds the
+reassembler. Datagrams go into a
scratch `byte[]` (size = max(PacketSize, 9000) + slack), parse the 8/20-byte header, and copy the payload
straight into the frame buffer at `(packetId - 1) * dataBytesPerPacket`. Track received packets per frame
in a bit array. A hole is an id below the highest id received so far; the not-yet-transmitted tail becomes
@@ -369,7 +416,11 @@ new frame only when it is not evidently a duplicate — a resent copy (status 0x
timestamp equals the closed frame's is dropped as a duplicate; a resent leader for a block older than the
newest one in flight is ignored; any other leader with an "old" block id is treated as the device having
restarted its block numbering (single-frame acquisitions restart at 1) and opens normally. Opening a block
-never marks the tail of a block that is not older than it. Only a few frames
+never marks the tail of a block that is not older than it. The newest frame that has received only its leader
+is exempt from `FrameRetentionMs` (a long exposure sends the leader first), so a restart can also land on a block
+id that is still in flight: a leader for that id that arrives after at least `PacketTimeoutMs` of silence, is not
+a resent copy and does not carry the same timestamp reopens the slot with the new leader; the leader-only frame
+is counted incomplete and raised through `FrameDropped` but never delivered. Only a few frames
are in flight at once (leader of frame N+1 may arrive while N waits for resends); frames close in block
order. Completed frames are pushed to a bounded queue; `ReceiveAsync` awaits it. When the pool is empty,
the incoming frame is dropped and `FramesDroppedNoBuffer` increments — the receiver never reuses a buffer
@@ -469,6 +520,18 @@ three GVBS strings and the URL register to build the key, never the XML region.
Read First URL (512 bytes) then Second URL as fallback. `Local:` addresses/lengths are hexadecimal without
`0x`. Read memory in `MaxMemPayload` chunks, rounding the length up to a multiple of 4 and trimming.
+Failure types are kept so a caller can tell "reconnect" from "bad XML": when the port reports device loss
+(`GevControlLostException`, `GevTimeoutException`, `ObjectDisposedException`) while reading a URL register, the
+cache key (when the cache is on) or `Local:` memory, that exception is rethrown unwrapped at once and the other URL
+is not tried (it goes through the same port and would only spend another retry budget) — on the second attempt too.
+A timeout whose other end was alive is not device loss and still falls back: an http download timeout, and a read
+the device answered with PENDING_ACK but did not finish within the allowed extension. The two are told apart by a
+mark the throwing site puts on the exception (the loader on its http timeout, the channel on the PENDING_ACK one),
+not by the URL kind — an http URL still reads its cache key from the device. Otherwise, if every
+attempted URL failed with the same exception type more specific than `GevException`, the first of them is rethrown;
+failures of different kinds (an empty register counts as one) are aggregated into a `GevException` carrying both
+reasons, the last one as `InnerException`. Timeouts are left out of that same-type rethrow and always aggregated, so
+a bare `GevTimeoutException` out of `LoadAsync`/`GetXmlAsync` always means device loss (a GVCP request with no reply).
Cache file name: `{Manufacturer}_{Model}_{DeviceVersion}_{FileName}` sanitized; cache is opt-in.
### GenApi (`GevSharp.GenApi`)
@@ -484,7 +547,9 @@ public partial class GenApiNodeMap
```
**Transport-layer lock.** `GevDevice.SetTlParamsLockedAsync(bool locked, ct) → Task` writes the
-`TLParamsLocked` node (returns false when the description has none). That node is *not* a device register —
+`TLParamsLocked` node and returns true. It returns false, writing nothing, when the description has no such
+node (logged at Debug — such a device does not use the lock) or declares it as something other than an
+integer node (logged at Warn with the node kind — whatever the description gates on it stays as it was). That node is *not* a device register —
it lives in the node map, and vendor descriptions gate features on it: on a Basler ace, `AcquisitionStart`
carries `ImposedAccessMode=WO` plus `pIsLocked = (TLParamsLocked = 0)`, so it reads as a locked write-only
node — i.e. `NotAvailable` — until the host sets the lock, and the format parameters (`Width`, `Height`,
@@ -557,8 +622,15 @@ model + port.
`pValue` to an Integer or Float, `DisplayNotation`/`DisplayPrecision`/`Unit`.
- Enumeration: entries with `Value`, `Symbolic`, `NumericValue`, own `pIsImplemented`/`pIsAvailable`;
value comes from `pValue` (Integer/IntReg/MaskedIntReg) or `Value` literal.
-- Command: `CommandValue`/`pCommandValue` written to `pValue`; `IsDone` = read `pValue` and compare with the
- command value when `PollingTime` is present, else true.
+- Command: `CommandValue`/`pCommandValue` written to `pValue`. `IsDone` is GevSharp's own rule, not checked
+ against a primary source: only when the Command node itself carries `PollingTime` (its presence is used, never
+ its interval) and the Command's effective access mode can read, `pValue` is re-read from the device and IsDone is
+ true once it no longer equals the command value (read as a self-clearing bit). Otherwise IsDone is true without
+ any wire read — no `PollingTime`, no `pValue`, or a write-only Command (also a locked one) — so on a description
+ without a Command-level `PollingTime` it is **not** a completion signal: a caller that must wait for a command
+ (a user-set load, say) reads a status node the device documents or waits a settle time of its own. When a
+ re-read is needed but the Command is not implemented or not available, IsDone throws `GenApiException` without
+ touching the port.
- Boolean: `OnValue`/`OffValue` (default 1/0) over `pValue` or literal `Value`.
- String: StringReg (fixed length, NUL-padded, ASCII/UTF-8 per device mode), literal `Value`.
- Port: `pPort` on every register node → `IPortNode.Port`; ignore `ChunkID`/`SwapEndianess`/`CacheChunkData`
@@ -588,7 +660,13 @@ GenApi runtime — implementation notes where the behaviour is more specific tha
written bytes; WriteAround/NoCache drop), and a chain walk stops at the written node so it is not undone.
Registers that share bytes without a graph edge (StructReg entries, alias registers) are found by address
overlap and dropped. `INode.Invalidate()` uses the same closure but includes the node itself and its whole
- value chain.
+ value chain. A node *declared* stale — the node `Invalidate()` is called on (or whose write threw), a
+ `pInvalidator` listener, a `pSelected` target — also has its chain walked through formula inputs: the value
+ `pVariable`s of a SwissKnife/IntSwissKnife/Converter/IntConverter, so a value that an IntSwissKnife assembles
+ from two cacheable latch registers is re-read. A node reached only as a dependent is stale because of the input
+ that led there, which is dropped on its own; its other formula inputs stay cached. `.Entry.` variables are
+ bind-time constants and `.Min`/`.Max`/`.Inc` variables read limits, so neither is part of the value chain (like
+ `pMin`/`pMax`/`pInc`).
- A write that **throws** is treated as "the device may hold the new value": a GVCP command leaves before its
acknowledge is awaited, so a lost reply, a timeout after PENDING_ACK or a cancelled wait all arrive here with
the device already changed. The register drops its own cache and every overlapping one, and the node drops
@@ -620,7 +698,7 @@ nowhere — every public type of `GevSharp` belongs to exactly one line here.
| Group | Types | Where it is specified |
|---|---|---|
| Discovery, device, stream | `GevDiscovery(Opt)`, `GevDeviceInfo`, `GevDevice`, `GevDeviceOpt`, `GevAccessMode`, `GevStream`, `GevStreamOpt`, `PacketSizeMode`, `GevFrame`, `GevStreamStats`, `GevStreamStatsSnap`, `GevFrameDiag`, `GevFrameDropReason` | the sections above |
-| Errors | `GevException`, `GevTimeoutException`, `GevStatusException`, `GevControlLostException`, `GevStreamClosedException`, `GenApiException` | "Errors" in CLAUDE.md; each carries the operation or node it failed on |
+| Errors | `GevException`, `GevTimeoutException`, `GevStatusException`, `GevControlLostException`, `GevStreamClosedException`, `GenApiException` | "Errors" in CLAUDE.md; each carries the operation or node it failed on. Outside this family, device operations also throw `ObjectDisposedException` (after `DisposeAsync`, or racing it) and `GevFrame.Data` does after the frame is disposed — "Device" above |
| Logging | `GevLog`, `GevLogLevel` | a sink the host installs once; the library writes nowhere by itself |
| Register boundary | `IGevPort` | the one seam between GenApi and a transport |
| GVCP wire | `GvcpConst`, `GvbsAddr`, `GvcpPacket`, `GvcpCmd`, `GvcpAck`, `GvcpCmdHeader`, `GvcpAckHeader`, `GvcpChannel`, `GvcpChannelOpt` | "GVCP channel" above and `docs/protocol-notes.md` |
@@ -665,7 +743,7 @@ nowhere — every public type of `GevSharp` belongs to exactly one line here.
node map read/write, streaming with injected packet loss → resend recovery, incomplete-frame policy,
buffer-pool exhaustion).
- End-to-end tests live in `tests/GevSharp.Tests/Integration/`: `SimRig` starts one `SimDevice` on
- `127.0.0.1:` and opens a `GevDevice` through the internal `OpenAsync(IPEndPoint, ...)` overload;
+ `127.0.0.1:` and opens a `GevDevice` through the `OpenAsync(IPEndPoint, ...)` overload;
acquisition is driven by writing `SimFeatureAddr` registers directly (no node map). `RecordingPort` wraps
the device's `IGevPort` to assert the order of stream-channel register accesses. Tests that assert exact
frame sequences drive the simulator in software-trigger mode (`SimRig.TriggerAsync`) instead of relying
@@ -723,8 +801,8 @@ timings on a slow host; acquisition goes through the `AcquisitionStart`/`Acquisi
transport-layer lock set around them — or through `--acq-start-addr`/`--acq-stop-addr` register writes when the node map
is unavailable), `regtest` (alternating reads of two registers while the heartbeat runs; mismatches and latency), and
`sim` (runs `GevSharp.Sim` as a standalone fake camera).
-Every `` accepts a `:port` suffix; a non-standard port uses the internal `OpenAsync(IPEndPoint)` /
-`ProbeAsync(IPEndPoint)` overloads, which `src/GevSharp/GevSharp.csproj` grants through
+Every `` accepts a `:port` suffix; a non-standard port uses the public `OpenAsync(IPEndPoint)` and the
+internal `ProbeAsync(IPEndPoint)` overload, which `src/GevSharp/GevSharp.csproj` grants through
`InternalsVisibleTo("GevSharp.Cli")`. Tests live in `samples/GevSharp.Cli/Tests` (excluded from the executable by
``) and are compiled into the suite by `tests/GevSharp.Tests` through a `ProjectReference`
plus ``. They run against `GevSharp.Sim` on
diff --git a/docs/design-requirements.md b/docs/design-requirements.md
index c2fc509..e7a95de 100644
--- a/docs/design-requirements.md
+++ b/docs/design-requirements.md
@@ -61,7 +61,7 @@ holds today and nothing would notice a regression. `partial` = some cases guarde
| R9 | met | `FormulaParser.cs:270,277` (depth 200); `NodeBinder.cs:179-239` — **iterative** DFS, so cyclic XML cannot overflow the stack | `NodeMapBindTests.ReferenceCycle_IsDetectedAtBind`, `FormulaTests.DeepParenthesesAreRejectedWithoutStackOverflow` |
| R10 | met | `GenApi/Runtime/IntegerNodes.cs:365-380` (`FieldOf` flips LSB/MSB for BigEndian) | `IntegerNodeTests.MaskedIntReg_BigEndian_Bit0_IsMostSignificantBit`, `MaskedIntReg_LittleEndian_Lsb0Msb7_IsLowByte`, `MaskedIntReg_BitBeyondRegister_FailsAtBind` |
| R11 | met | `Gvsp/GevStream.PacketSize.cs:26-65,78,157`; SCPD from the option only | `PacketSizeNegotiationTests.*`, `StreamingScenarioTests.Start_AccessesChannelRegistersInTheDocumentedOrder_AndStopReversesIt` |
-| R12 | met | `Gvsp/GevStream.cs` (granted socket buffer read back and logged, dedicated receiver thread), `Receiver.cs` (one reusable scratch buffer, pooled slots and frame buffers) | `ReceiverAllocationTests` — the receiver's per-datagram work is called on the test thread (`FeedPacketForTest`), so `GC.GetAllocatedBytesForCurrentThread` can see it: 0 bytes across 700+ packets, 0 bytes for late/duplicate packets of a closed block, and completing a frame allocates only the `GevFrame` object (≤ 256 bytes, not the 64 KiB image). Mutation-checked: one unguarded interpolated `GevLog.Debug` on the packet path fails both. `GvcpChannelTests.PacketResendDoesNotAllocateOnTheHotPath` guards the GVCP side |
+| R12 | met (one runtime exception, 2026-09-26) | **Exception:** on .NET Framework (the netstandard2.0 asset) the receiver's wait allocates — `Socket.Poll` there allocates a small array per call (about 40 B, measured; 0 B on net6/net8), and the receiver polls whenever the socket is empty, about once per packet at full rate. Accepted: the previous zero-allocation wait (blocking receive with a socket receive timeout) lost datagrams on Windows (`docs/evaluation.md`, "Receive wait on Windows"). `ReceiverAllocationTests` feed packets past the socket, so they do not see the wait. Otherwise: `Gvsp/GevStream.cs` (granted socket buffer read back and logged, dedicated receiver thread), `Receiver.cs` (one reusable scratch buffer, pooled slots and frame buffers) | `ReceiverAllocationTests` — the receiver's per-datagram work is called on the test thread (`FeedPacketForTest`), so `GC.GetAllocatedBytesForCurrentThread` can see it: 0 bytes across 700+ packets, 0 bytes for late/duplicate packets of a closed block, and completing a frame allocates only the `GevFrame` object (≤ 256 bytes, not the 64 KiB image). Mutation-checked: one unguarded interpolated `GevLog.Debug` on the packet path fails both. `GvcpChannelTests.PacketResendDoesNotAllocateOnTheHotPath` guards the GVCP side |
| R13 | met | `Gvcp/GevDiscovery.cs` — `SelectInterfaces` enumerates every up IPv4 interface, `BuildTargets` forms the limited and directed broadcasts; `GevNet.cs` (`GetIpv4Interfaces`, `DirectedBroadcast`) | `GevDiscoveryTests.Probe*`, `DiscoverCollectsRepliesFromEveryTargetAndDedupesByMac`, and `DiscoveryBroadcastTests` (11 cases: per-mask directed address, unknown mask, /0 collapsing onto the limited one, unicast appended not substituted, loopback opt-in enumeration, and both broadcasts observed leaving the socket) — mutation-checked: forcing `DirectedBroadcast` off fails 7 |
| R14 | met | `GevDiscovery.cs:214-218,284-288`; `GevDeviceInfo.cs:52` | `GevDiscoveryTests.DiscoverSkipsTruncatedRepliesInsteadOfCreatingGhosts`, `ProbeSkipsTruncatedDiscoveryAck` |
| R15 | met | `Gvcp/GvbsAddr.cs` (the offset table), `GevDeviceInfo.cs:55-101`, `GevDevice.cs:113-116` — the only name lookup in the library is `TLParamsLocked`, which is not a bootstrap register | `GevDeviceInfoReadTests.EveryFieldIsReadAtItsOwnAddress`, `OpenIsNotFooledByADeviceWhoseBulkReadSkipsUnimplementedWords` |
diff --git a/docs/evaluation.md b/docs/evaluation.md
index d3a6916..e7238db 100644
--- a/docs/evaluation.md
+++ b/docs/evaluation.md
@@ -222,8 +222,39 @@ resumes later, would otherwise see the stream go permanently silent with no erro
Three defects were found here that no simulator run had shown, each now fixed and guarded by a test:
the transport-layer lock (`TLParamsLocked`) that gates the acquisition commands, the host firewall that
silently swallowed every GVSP packet, and GenApi addresses above 32 bits that made the whole File Access
-category unreadable. A fourth was cosmetic but real: a blocking receive can return `IOPending` on Windows,
-which the receiver logged as an error and answered with a sleep.
+category unreadable. A fourth looked cosmetic at the time: a blocking receive can return `IOPending` on Windows,
+which the receiver logged as an error and answered with a sleep. It was not cosmetic — see the next section.
+
+### Receive wait on Windows: a timed-out blocking receive loses datagrams (2026-09-26)
+
+*Bench measurement with the CLI harness (`samples/GevSharp.Cli`): protocol layer only, no consumer application in the path.*
+
+Up to 0.4.1 the receiver waited for packets with a blocking receive and a socket receive timeout
+(`SO_RCVTIMEO`), 2 ms while a frame is being assembled and 200 ms when idle, and treated `IOPending` like a
+timeout. Windows documents the socket state after a timed-out blocking receive as indeterminate, and in
+practice a datagram that arrives at the moment the timeout expires can be lost. A loopback probe under load
+lost exactly as many datagrams as it saw `IOPending` returns.
+
+On hardware the condition is packets spaced wider than the wait interval, so that every packet arrives just
+after a wait ends: slow senders, a large inter-packet delay (SCPD), bandwidth shared between cameras, or a
+device that sends the leader long before the payload. Basler acA2500-14gm, Mono8 2592x1944, SCPD 300000 ticks
+(about 2.4 ms between packets), resend off so a loss is not hidden, three concurrent test runs as CPU load,
+60 s per run:
+
+| Receiver | Run | Blocks | Incomplete | Missing packets |
+|---|---|---|---|---|
+| 0.3.0 CLI (`SO_RCVTIMEO` wait) | 1 | 46 | 9 | 10 |
+| 0.3.0 CLI (`SO_RCVTIMEO` wait) | 2 | 39 | 8 | 9 |
+| this tree (non-blocking receive + `Poll`) | 1 | 39 | 0 | 0 |
+| this tree (non-blocking receive + `Poll`) | 2 | 39 | 0 | 0 |
+
+With resend on, each such loss costs a resend request instead of a frame, which is why the full-rate runs
+above never showed it: back-to-back packets do not leave the 2 ms gaps. At full rate the new wait changes
+nothing measurable: 120 s, 1752 frames, 989,880 packets, 14.59 fps, 0 resend requests, the same as before.
+The receiver now receives non-blocking while data is queued and waits with `Poll` (which does not consume
+data) only when the socket is empty. `GevSharp.Tests` has an opt-in load test for the loopback case
+(`ReceiveWaitLossTests`, `GEVSHARP_STRESS=1`); it did not reproduce the loss in 2,800 frames on the old code,
+so the hardware table is the evidence.
### Odd-width GVSP Packed line rule — settled by measurement, and we had it wrong
@@ -273,6 +304,28 @@ clamped at the expected size. This matters since frame completion also requires
frame closed as incomplete, with the one-time warning naming both sizes. Not observed on this camera; the rule
is left as is until a device shows it, and the warning is what would surface it.
+**Blocks at acquisition stop (2026-09-26, reported through the CvInspect adapter — not a CLI harness run).** The
+same R29 check closes a block that a device cuts short with an early trailer. The consumer ran GevSharp 0.4.1
+through its adapter on this camera with single grabs, live bursts, 1 ms-timeout grabs that stop acquisition while
+a frame is in flight, and cancellations 0–3 ms after the call. The first run ended with 0 incomplete. The second
+run of the same procedure had **one cut block**: during the cancellation series, block 55 ended with a trailer
+after 431 payload packets — 3,856,896 of the 5,038,848 bytes the leader announced — and was closed as
+incomplete with the one-time warning. This is the first hardware observation of the case R29 guards: 0.4.0 would
+have delivered that frame as complete with the previous frame's pixels in its missing part. So this Basler
+**sometimes** cuts the block it is sending when acquisition is stopped; one clean run did not show it, and one
+such run is not evidence that a model never cuts. In the same run the next single grab timed out once (2 s).
+
+The consumer then ran a 0.4.0 control with the same adapter source and procedure (cancel 0–3 ms after the call ×20,
+then five normal grabs; eight rounds). Both versions saw the device cut blocks with an early trailer ("trailer sets
+packet count 563 -> 552" and similar), and some of the cut blocks were the frame of the **normal grab right after
+a cancellation round** — no packet was missing and no resend was asked; the device sent the trailer early.
+0.4.1 closed those frames as incomplete, so that grab timed out (packets arrived in the window, 0 completed,
+1 incomplete). 0.4.0 returned the cut frame as that grab's answer, marked complete, with its last 12–14 packets'
+worth of bytes still holding the previous frame. So the timeout is not a receiver regression: it is the same
+device behaviour, now reported instead of delivered as a wrong image. Scope: this one camera, grabs right after a
+cancelled grab, intermittent (2 of 8 rounds per version). Why the device cuts the next grab's block was not
+measured. Figures and raw logs are the consumer's (its `cvinspect-0290-cutloop` run); see its records.
+
`PixelFormatInfo.FrameBytes` is the single definition of that and `GvspImageLeader.ImageBytes` routes
through it, so the receiver sizes a frame the way the device does. Where a line is not a whole number of
bytes and there is no line padding there is no stride at all, and saying so is part of the fix:
diff --git a/docs/genapi-model.md b/docs/genapi-model.md
index ac1cd4e..468fb83 100644
--- a/docs/genapi-model.md
+++ b/docs/genapi-model.md
@@ -86,7 +86,7 @@ all four float kinds → `Float`, `Node`/`Unknown` → `Unknown`).
| `IsStreamable` | bool | `Streamable` | default false |
| `PErrors` | IReadOnlyList\ | `pError`* | |
| `IsDeprecated` | bool | `IsDeprecated` | default false |
-| `PollingTimeMs` | long? | `PollingTime` | register nodes: treat reads as NoCache; Command: completion polling |
+| `PollingTimeMs` | long? | `PollingTime` | only its presence is used, never the interval: register nodes treat reads as NoCache; a Command's `IsDone` re-reads `pValue` (GevSharp's own rule). The caller picks the polling interval |
| `PSelected` | IReadOnlyList\ | `pSelected`* | accepted on any kind; meaningful on Integer kinds, Enumeration, Boolean. `pSelecting` is derived by the runtime |
`MergePriority`/`ExposeStatic` attributes and `` are ignored.
diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md
index 6a2517e..c59634c 100644
--- a/docs/protocol-notes.md
+++ b/docs/protocol-notes.md
@@ -50,7 +50,9 @@ See `GvbsAddr`. Highlights:
- `0x0200` first URL (512), `0x0400` second URL (512) — camera XML location.
- `0x0934` GVCP capability bits (bit2 packet resend, bit5 pending ack, bit29 heartbeat disable, …),
`0x0938` heartbeat timeout (ms), `0x093C/0x0940` timestamp tick frequency (Hz, 64-bit),
- `0x0944` timestamp control (write 2 = reset, 1 = latch), `0x0948/0x094C` latched timestamp.
+ `0x0944` timestamp control (write 1 = reset, 2 = latch — the values real device descriptions put in
+ `GevTimestampControlReset`/`GevTimestampControlLatch`; an earlier revision of this line had them swapped),
+ `0x0948/0x094C` latched timestamp.
- `0x0A00` CCP: 1 = exclusive, 2 = control, 4 = control switchover enable; 0 = open. Writing CCP requires
no privilege when the register is 0; writes from a non-controlling host return `ACCESS_DENIED (0x8006)`.
`0x0A04`/`0x0A14` primary application port/IP — the socket of whoever holds control, which answers
@@ -243,7 +245,10 @@ into a node map is a later milestone.
`` on selector features (the selected features are those listed).
- Guards: ``, ``, `` (Integer/Boolean/SwissKnife nodes: non-zero = true),
``, `` (nodes whose write invalidates this node's cache), ``.
-- Commands: `` / `` written to ``; `` marks self-clearing bits.
+- Commands: `` / `` written to ``. `` on a Command is *read by
+ GevSharp* as marking a self-clearing bit that `IsDone` may poll — GevSharp's own reading, not a rule taken from a
+ primary source (none checked). Descriptions often leave it out, also on commands that take time to finish
+ (a user-set load), and others put it on exactly those commands.
- Booleans: `` / `` (default 1 / 0).
- Strings: `` fixed `Length`, `` literal or `pValue`.
- Floats: `` Length 4/8 IEEE; ``; `` with `Min/Max/Inc/Unit/Representation/DisplayNotation/DisplayPrecision`.
diff --git a/docs/sim-register-map.md b/docs/sim-register-map.md
index 5ef5153..7eb9ebf 100644
--- a/docs/sim-register-map.md
+++ b/docs/sim-register-map.md
@@ -59,9 +59,9 @@ of this block, verbatim.
| `0x0930` MessageChannelCapability | 4 | RO | 0 | — |
| `0x0934` GvcpCapability | 4 | RO | concatenation \| write-mem \| packet-resend \| CCP-app-socket \| serial-number \| name-register; + pending-ack when `SupportPendingAck`. Heartbeat-disable is **not** set. | — |
| `0x0938` HeartbeatTimeout | 4 | RW | `HeartbeatTimeoutMs` (3000). 0 = never expire. | `GevHeartbeatTimeout` (Integer → IntReg) |
-| `0x093C` / `0x0940` TimestampTickFreq | 8 | RO | `1_000_000_000` (1 GHz — ticks are nanoseconds) | `TimestampTickFrequency` (Integer → 8-byte IntReg) |
-| `0x0944` TimestampControl | 4 | W→0 | bit1 (value 2) resets the counter, bit0 (value 1) latches it | `TimestampLatch` (Command, value 1) |
-| `0x0948` / `0x094C` TimestampLatched | 8 | RO | 0 until the first latch | `TimestampLatchValue` (Integer → 8-byte IntReg, NoCache) |
+| `0x093C` / `0x0940` TimestampTickFreq | 8 | RO | `1_000_000_000` (1 GHz — ticks are nanoseconds) | `TimestampTickFrequency`, `GevTimestampTickFrequency` (Integer → 8-byte IntReg) |
+| `0x0944` TimestampControl | 4 | W→0 | value 1 restarts the counter at 0 (the latched value is left alone), value 2 latches it into `0x0948`; 3 does both, reset first | `TimestampReset`, `GevTimestampControlReset` (Command, value 1); `TimestampLatch`, `GevTimestampControlLatch` (Command, value 2) |
+| `0x0948` / `0x094C` TimestampLatched | 8 | RO | 0 until the first latch | `TimestampLatchValue`, `GevTimestampValue` (Integer → 8-byte IntReg, NoCache) |
| `0x0950` DiscoveryAckDelay | 4 | RW | 0 (not honoured) | — |
| `0x0954` GvcpConfig | 4 | RW | 0 (not honoured) | — |
| `0x0958` PendingTimeout | 4 | RO | `PendingAckDelayMs` | — |
@@ -69,6 +69,15 @@ of this block, verbatim.
| `0x0A04` PrimaryAppPort | 4 | RO | 0; the CCP writer's UDP port while controlled | — |
| `0x0A14` PrimaryAppIp | 4 | RO | 0; the CCP writer's IPv4 while controlled | — |
+Timestamp nodes come in two naming families over the same registers, so host code written for either finds
+them: `TimestampReset`/`TimestampLatch`/`TimestampLatchValue`/`TimestampTickFrequency` (category
+`DeviceControl`) and the transport-layer names `GevTimestampControlReset`/`GevTimestampControlLatch`/
+`GevTimestampValue`/`GevTimestampTickFrequency` (category `TransportLayerControl`) — the latter are what GigE
+camera descriptions commonly carry, and what a host that pairs frames with the device clock looks up. The
+latch reads the same monotonic counter that stamps the image leaders. The control values follow those
+descriptions (reset 1, latch 2); earlier revisions of the simulator had them swapped, so a standard latch
+reset the counter instead.
+
## Stream channel 0 (`0x0D00`)
| Address | Access | Reset value | Meaning | XML node |
@@ -89,23 +98,23 @@ of this block, verbatim.
|---|---|---|---|---|---|
| `0x10000` | Width | RW | `Opt.Width` (640) | frame width in pixels | `Width` (Integer Min 8, pMax WidthMax, Inc 4, pIsLocked AcquisitionActive) → `WidthReg` |
| `0x10004` | Height | RW | `Opt.Height` (480) | frame height in pixels | `Height` (Integer Min 8, pMax HeightMax, Inc 2, pIsLocked) → `HeightReg` |
-| `0x10008` | OffsetX | RW | 0 | copied into the leader | `OffsetX` (Integer 0..4088 Inc 4) → `OffsetXReg` |
-| `0x1000C` | OffsetY | RW | 0 | copied into the leader | `OffsetY` (Integer 0..4094 Inc 2) → `OffsetYReg` |
+| `0x10008` | OffsetX | RW | 0 | copied into the leader | `OffsetX` (Integer 0..4088 Inc 4, pIsLocked) → `OffsetXReg` |
+| `0x1000C` | OffsetY | RW | 0 | copied into the leader | `OffsetY` (Integer 0..4094 Inc 2, pIsLocked) → `OffsetYReg` |
| `0x10010` | PixelFormat | RW | `Opt.PixelFormat` (Mono8 `0x01080001`) | PFNC code; bits 23..16 give bits per pixel for the frame size | `PixelFormat` (Enumeration: Mono8, Mono10, Mono12, Mono16, BayerRG8, RGB8; pIsLocked) → `PixelFormatReg` |
| `0x10014` | ExposureTimeRaw | RW | 10 000 000 (10 ms) | exposure in timestamp ticks; no effect on timing | `ExposureTimeRaw` (Integer 1000..2e9) → `ExposureTimeRawReg`; `ExposureTime` (Converter, µs: `FormulaFrom = TO * 1000000.0 / TICKFREQ`, `FormulaTo = FROM * TICKFREQ / 1000000`, TICKFREQ = TimestampTickFrequency) |
| `0x10018` | GainSelector | RW | 0 | index 0..2 into the GainRaw block | `GainSelector` (Enumeration AnalogAll/DigitalAll/DigitalRed, pSelected Gain, GainRaw) → `GainSelectorReg` |
| `0x1001C` + 4·n | GainRaw[n], n = 0..2 | RW | 0 | 0.1 dB units | `GainRaw` (Integer 0..1023) → `GainRawReg` (Address 0x1001C, `pIndex Offset=4` GainSelectorReg); `Gain` (Converter dB: `FormulaFrom = TO / 10.0`, `FormulaTo = FROM * 10`) |
| `0x10028` | TriggerControl | RW | 0 | integer bit 0 = TriggerMode (1 = On), bits 7..4 = TriggerSource (0 Software, 1 Line0, 2 Line1) | StructReg → `TriggerModeReg` (Bit 31), `TriggerSourceReg` (LSB 27 / MSB 24); `TriggerMode` (Enumeration Off/On), `TriggerSource` (Enumeration, pIsAvailable TriggerModeIsOn) |
-| `0x1002C` | AcquisitionMode | RW | 0 | 0 Continuous, 1 SingleFrame, 2 MultiFrame | `AcquisitionMode` (Enumeration) → `AcquisitionModeReg` |
+| `0x1002C` | AcquisitionMode | RW | 0 | 0 Continuous, 1 SingleFrame, 2 MultiFrame | `AcquisitionMode` (Enumeration, pIsLocked) → `AcquisitionModeReg` |
| `0x10030` | AcquisitionStart | SC | 0 | 1 starts the sender thread | `AcquisitionStart` (Command value 1, PollingTime 10) → `AcquisitionStartReg` (NoCache) |
| `0x10034` | AcquisitionStop | SC | 0 | 1 stops the sender and waits for it | `AcquisitionStop` (Command value 1, PollingTime 10) → `AcquisitionStopReg` (NoCache) |
-| `0x10038` | AcquisitionStatus | RO | 0 | 1 while the sender thread runs | `AcquisitionActive` (Integer, Guru) → `AcquisitionActiveReg` (NoCache); the pIsLocked predicate of Width/Height/PixelFormat |
+| `0x10038` | AcquisitionStatus | RO | 0 | 1 while the sender thread runs | `AcquisitionActive` (Integer, Guru) → `AcquisitionActiveReg` (NoCache); the pIsLocked predicate of AcquisitionMode, Width, Height, OffsetX, OffsetY, PixelFormat and ReverseX |
| `0x1003C` | AcquisitionFrameRate | RW | `Opt.FrameRateHz` (30) as IEEE-754 binary32 big-endian | frame period in free-running mode; NaN/0/negative → 1 Hz | `AcquisitionFrameRate` (Float 1..1000 Hz) → `AcquisitionFrameRateReg` (FloatReg 4) |
| `0x10040` | TestPattern | RW | 1 | 0 Off (all zero), 1 DiagonalRamp, 2 FrameCounter | `TestPattern` (Enumeration) → `TestPatternReg` |
| `0x10044` | UserSetSelector | RW | 0 | 0 Default, 1 UserSet1 (both load the same defaults) | `UserSetSelector` (Enumeration, pSelected UserSetLoad) → `UserSetSelectorReg` |
| `0x10048` | UserSetLoad | SC | 0 | 1 restores the feature page | `UserSetLoad` (Command value 1, PollingTime 10) → `UserSetLoadReg` (NoCache) |
| `0x1004C` | AcquisitionFrameCount | RW | 1 | frames per start in MultiFrame mode | `AcquisitionFrameCount` (Integer 1..65535, pIsAvailable AcquisitionModeIsMultiFrame) → `AcquisitionFrameCountReg` |
-| `0x10050` | ReverseX | RW | 0 | 0/1; the pattern is not mirrored | `ReverseX` (Boolean) → `ReverseXReg` |
+| `0x10050` | ReverseX | RW | 0 | 0/1; the pattern is not mirrored | `ReverseX` (Boolean, pIsLocked) → `ReverseXReg` |
| `0x10054` | WidthMax | RO | 4096 | — | `WidthMax` (Integer) → `WidthMaxReg` |
| `0x10058` | HeightMax | RO | 4096 | — | `HeightMax` (Integer) → `HeightMaxReg` |
| `0x1005C` | FrameCounter | RO | 0 | frames sent since construction | — |
@@ -137,6 +146,11 @@ the receiver reports `Stride` 0).
- One server thread; commands are processed one at a time in arrival order. Every reply echoes `req_id`.
`ack_required = 0` → no reply (the command is still executed). Replies come from the GVCP socket.
+- **No duplicate suppression.** The responder keeps no memory of `req_id`: a retransmitted command (same
+ bytes, same `req_id`) is executed again, and a self-clearing one (AcquisitionStart, TriggerSoftware,
+ UserSetLoad, TimestampControl) acts twice. The library resends with the same `req_id` when an ACK has not
+ arrived within `GvcpTimeoutMs`, so a round trip slower than that window runs the command twice — e.g. a
+ second SingleFrame start and one frame too many. Pinned by `SimGvcpTests.RetransmittedCommand_*`.
- **DISCOVERY** → 248-byte `DISCOVERY_ACK`. Only unicast to `GvcpEndPoint` is answered, on whatever port the
socket has. Broadcast DISCOVERY is never seen: the socket is bound to `BindAddress` (a unicast address), and a
unicast-bound UDP socket does not receive datagrams sent to a broadcast address. `GvcpPort = 3956` only makes
@@ -160,6 +174,17 @@ the receiver reports `Stride` 0).
(checked every ≤ 20 ms), CCP is cleared, `ControlOwner` becomes null, `HeartbeatTimeouts` increments and
`ControlOwnerChanged(null)` fires. `HeartbeatTimeout = 0` disables expiry. Owner reads of CCP increment
`HeartbeatObserved`.
+- **Reboot**: `Reboot()` emulates a power cycle without giving up the socket, so the endpoint stays the
+ same. Between two commands it stops acquisition, drops the owner (`ControlOwnerChanged(null)` fires if there
+ was one, on the calling thread and before the next command is handled, so it always precedes a new owner),
+ and returns every volatile register to its power-on value: CCP, PrimaryAppPort/Ip,
+ HeartbeatTimeout (`SimDeviceOpt.HeartbeatTimeoutMs`), GvcpConfig, TimestampControl, the latched timestamp,
+ SCP/SCPS/SCPD/SCDA/SCCFG, and the feature page (as `UserSetLoad`). The timestamp counter restarts at 0 and the
+ next frame is block 1. Persistent IP, `UserDefinedName`, the observation counters and `FrameCounter` survive.
+ A controlling host's next heartbeat reads CCP = 0 well within its device timeout, so `GevDevice` reports
+ control lost with the "... or the device restarted" reason, and a new session can take control at once.
+ The time a real camera spends offline while rebooting is not emulated. `Stop()`/`Start()` is **not** a
+ reboot: the owner, CCP and all registers survive, and with an ephemeral `GvcpPort` the port changes.
- **PENDING_ACK** (`SupportPendingAck`): every acknowledged WRITEREG first gets `PENDING_ACK` with
time = `PendingAckDelayMs`, then the real `WRITEREG_ACK` after that delay (the server thread sleeps).
- **PACKETRESEND**: never acknowledged. Accepted only from the owner and for channel 0; anything else is
@@ -172,6 +197,20 @@ the receiver reports `Stride` 0).
65536 bytes, so every legal UDP datagram fits; should the socket ever report an oversize datagram
(`MessageSize`) it is counted as malformed on every platform rather than as a socket error.
+## Host timing for tests against the simulator
+
+The simulator answers at once, but a loaded CI runner does not schedule anyone at once. `GevDeviceOpt`'s
+defaults are production values — `GvcpTimeoutMs` 500 with `GvcpRetries` 3, and `HeartbeatTimeoutMs` 3000,
+which makes the heartbeat period 1000 ms — and on a starved runner both have failed against the simulator,
+as recorded in `tests/GevSharp.Tests/Integration/SimRig.cs`: a loopback round trip took longer than a 1 s GVCP
+window, so the WRITEREG was resent and executed twice (see "No duplicate suppression" above), and a 1 s period
+against a 3 s device timeout lost control. `SimRig.DefaultDeviceOpt()` therefore uses `GvcpTimeoutMs` 3000
+with one retry, and `HeartbeatTimeoutMs` 10 000 with a 500 ms period; tests that exercise expiry set their own
+values. A downstream suite that drives the simulator should widen the same two settings rather than inherit the
+production defaults — otherwise its flaky failures look like its own bugs (an extra frame after a single grab,
+a spurious `ControlLost`). Widening costs nothing on the normal path: these are budgets for a missing reply,
+not expected response times.
+
## GVSP behaviour
- `AcquisitionStart` starts a sender thread; frames go out only while `SCP ≠ 0` and `SCDA ≠ 0` (the loop keeps
@@ -210,8 +249,14 @@ the receiver reports `Stride` 0).
- Unicast discovery only — broadcast DISCOVERY never reaches the unicast-bound socket, whatever `GvcpPort`
is. `BindAddress` must be IPv4 (the constructor throws `ArgumentException` otherwise).
- One stream channel, no message channel, no events, no actions, no chunk data, no manifest table.
-- Width/Height/PixelFormat are not refused while acquiring — the lock lives in the XML (`pIsLocked`); a
- change takes effect from the next frame.
+- The acquisition lock lives in the XML only. `AcquisitionMode`, `Width`, `Height`, `OffsetX`, `OffsetY`,
+ `PixelFormat` and `ReverseX` carry `pIsLocked = AcquisitionActive`, so a node-map write while the sender runs
+ fails with a "locked" `GenApiException`, as on cameras that lock these features during acquisition. A raw
+ WRITEREG/WRITEMEM to the same registers is not refused (tests that drive `SimFeatureAddr` directly rely on
+ that); a format change made that way takes effect from the next frame. The lock follows `AcquisitionStatus`,
+ which drops as soon as a SingleFrame/MultiFrame run has sent its frames — the lock ends there, not at the
+ next `AcquisitionStop`. `TriggerMode`/`TriggerSource` are left unlocked: cameras differ there (some lock
+ them while acquiring, some only while `TLParamsLocked` is set), so the simulator does not pick one.
- The device does not stop streaming when control is lost; SCP stays as written.
- FORCEIP does not rebind sockets. `DiscoveryAckDelay`, `GvcpConfig`, SCPS bits 30/29 are stored but unused.
- SCPD timing and the frame period are best effort on a general-purpose OS. A test may bound them only with a
diff --git a/samples/GevSharp.Cli/Commands/DeviceTarget.cs b/samples/GevSharp.Cli/Commands/DeviceTarget.cs
index 4e1093a..c12c65a 100644
--- a/samples/GevSharp.Cli/Commands/DeviceTarget.cs
+++ b/samples/GevSharp.Cli/Commands/DeviceTarget.cs
@@ -5,7 +5,8 @@ namespace GevSharp.Cli.Commands;
///
/// 명령의 <ip> 인자 — "192.168.1.10" 또는 "127.0.0.1:4000". 포트를 생략하면 표준 GVCP 포트(3956).
-/// 표준 포트는 공개 OpenAsync(IPAddress) 로 열고, 다른 포트(시뮬레이터 등)는 IPEndPoint 오버로드로 연다.
+/// 표준 포트는 공개 OpenAsync(IPAddress) 로 열고, 다른 포트(시뮬레이터 등)는 공개 OpenAsync(IPEndPoint) 로 연다.
+/// 프로브만은 포트를 받는 오버로드가 내부 멤버라 InternalsVisibleTo 로 쓴다.
///
public sealed class DeviceTarget
{
@@ -49,7 +50,10 @@ public Task OpenAsync(GevDeviceOpt opt, CancellationToken ct)
? GevDevice.OpenAsync(Address, opt, ct)
: GevDevice.OpenAsync(EndPoint, opt, ct);
- /// 유니캐스트 DISCOVERY_CMD 한 번. 응답이 없으면 null.
+ ///
+ /// 유니캐스트 DISCOVERY_CMD 한 번. 쓸 수 있는 응답이 없으면 null — 시간 안에 응답이 없을 때만이 아니라 장치가 오류 status 로 답했을 때,
+ /// 응답이 탐색 블록보다 짧을 때도 null 이다(뒤의 둘은 라이브러리가 Warn 로그로 남긴다). 예외는 와 같다.
+ ///
public Task ProbeAsync(int timeoutMs, CancellationToken ct)
=> IsStandardPort
? GevDiscovery.ProbeAsync(Address, timeoutMs, ct)
diff --git a/samples/GevSharp.Cli/Commands/DiscoverCmd.cs b/samples/GevSharp.Cli/Commands/DiscoverCmd.cs
index cfd2685..4dff216 100644
--- a/samples/GevSharp.Cli/Commands/DiscoverCmd.cs
+++ b/samples/GevSharp.Cli/Commands/DiscoverCmd.cs
@@ -18,7 +18,7 @@ public sealed class DiscoverCmd : ICliCommand
" --timeout ms reply collection window in milliseconds (default 1000)\n" +
" --interface ip host interface to scan; repeatable (default: every IPv4 interface that is up, loopback excluded)\n" +
" --probe ip[:port] send one unicast DISCOVERY_CMD to that address instead of broadcasting. Reaches devices behind\n" +
- " a router and loopback simulators, which never see a broadcast. Exit code 2 when nothing answers.\n" +
+ " a router and loopback simulators, which never see a broadcast. Exit code 2 when no usable reply comes back.\n" +
" Columns: IP, MAC, manufacturer, model, device version, serial number, user-defined name, interface that heard\n" +
" the reply. Everything shown comes from the discovery reply itself; no session is opened.";
@@ -37,7 +37,8 @@ public async Task RunAsync(CliArgs args, CancellationToken ct)
var info = await target.ProbeAsync(timeoutMs, ct);
if (info is null)
{
- Console.Error.WriteLine($"no reply from {target} within {timeoutMs} ms");
+ // null 은 무응답만이 아니다 — 오류 status·짧은 응답도 null 이고, 그 둘은 라이브러리가 경고로 남긴다(위에 찍힌다).
+ Console.Error.WriteLine($"no usable discovery reply from {target} within {timeoutMs} ms (a reply with an error status or a short payload is logged above)");
return CliExitCode.Device;
}
devices = new[] { info };
diff --git a/samples/GevSharp.Cli/Tests/CliRunTests.cs b/samples/GevSharp.Cli/Tests/CliRunTests.cs
index 36dd2a7..3b67a14 100644
--- a/samples/GevSharp.Cli/Tests/CliRunTests.cs
+++ b/samples/GevSharp.Cli/Tests/CliRunTests.cs
@@ -175,7 +175,7 @@ public async Task ProbeWithoutAnAnswerExits2()
var (code, _, stderr) = await RunAsync("discover --probe 127.0.0.1:1 --timeout 200");
Assert.Equal(CliExitCode.Device, code);
- Assert.Contains("no reply", stderr);
+ Assert.Contains("no usable discovery reply", stderr);
}
[Fact]
diff --git a/src/GevSharp/GenApi/GenApiNodeMap.Runtime.cs b/src/GevSharp/GenApi/GenApiNodeMap.Runtime.cs
index 5714503..c6cf8a9 100644
--- a/src/GevSharp/GenApi/GenApiNodeMap.Runtime.cs
+++ b/src/GevSharp/GenApi/GenApiNodeMap.Runtime.cs
@@ -11,8 +11,9 @@ namespace GevSharp.GenApi;
///
///
/// 무효화: 노드 X 가 쓰이면 X 를 p* 로 참조하는 노드, X 를 pInvalidator 로 지목한 노드, X 가 셀렉터일 때 pSelected 대상 — 그리고 그들에게서
-/// 같은 규칙으로 닿는 노드 전부 — 의 캐시를 버린다(값 사슬 아래의 레지스터 캐시까지, pIndex 슬롯 전부 포함). 쓰인 레지스터 자신의 캐시는
-/// Cachable 정책이 정한다(WriteThrough 는 쓴 값을 남긴다). 는 쓰기 없이 같은 전파를 하되 자기 자신도 버린다.
+/// 같은 규칙으로 닿는 노드 전부 — 의 캐시를 버린다(값 사슬 아래의 레지스터 캐시까지, pIndex 슬롯 전부 포함). 낡았다고 선언된 노드
+/// (무효화 대상·pInvalidator 청취자·pSelected 대상)는 수식 노드의 pVariable 입력까지 내려가 버리고, 의존으로만 닿은 노드는 그 입력에서 멈춘다.
+/// 쓰인 레지스터 자신의 캐시는 Cachable 정책이 정한다(WriteThrough 는 쓴 값을 남긴다). 는 쓰기 없이 같은 전파를 하되 자기 자신도 버린다.
/// 쓰기가 예외로 끝나면 장치가 값을 받았는지 모르므로(명령은 응답 전에 이미 나간다) 와 같이 자기 자신까지 버린다.
///
///
@@ -82,9 +83,9 @@ public static GenApiNodeMap Parse(GenApiXmlModel model, IGevPort port)
internal void OnWritten(NodeBase node)
{
var closure = Closure(node);
- foreach (var n in closure)
+ foreach (var (n, isDeclared) in closure)
{
- if (!ReferenceEquals(n, node)) NodeBase.DropCacheChain(n, node);
+ if (!ReferenceEquals(n, node)) NodeBase.DropCacheChain(n, node, throughFormulas: isDeclared);
}
}
@@ -119,36 +120,43 @@ internal void OnRegisterWriteFailed(RegisterCore core, ulong address, int length
///
internal void OnWriteFailed(NodeBase node) => InvalidateNode(node);
- /// — 노드 자신과 값 사슬, 그리고 의존 닫힘 전체의 캐시를 버린다.
+ /// — 노드 자신과 값 사슬(수식 노드의 pVariable 입력 포함), 그리고 의존 닫힘 전체의 캐시를 버린다.
internal void InvalidateNode(NodeBase node)
{
- foreach (var n in Closure(node)) NodeBase.DropCacheChain(n);
+ foreach (var (n, isDeclared) in Closure(node)) NodeBase.DropCacheChain(n, throughFormulas: isDeclared);
}
///
/// 무효화 닫힘: 시작 노드에서 의존 노드(p* 참조의 역방향)·pInvalidator 청취자·pSelected 대상을 따라 닿는 모든 노드(시작 노드 포함).
/// 셀렉터의 역방향(pSelecting)은 따르지 않는다 — 선택된 피처를 써도 셀렉터는 그대로다.
+ ///
+ /// 노드마다 "낡았다고 선언됐는지" 를 함께 돌려준다 — 시작 노드, pInvalidator 청취자, pSelected 대상은 참(값 자체가 바뀌었다고
+ /// 선언됐으니 수식 입력까지 버린다), 의존으로만 닿은 노드는 거짓(낡은 입력 때문에 낡은 것이라 그 입력만 버려지면 된다).
+ /// 두 길로 닿으면 참이 이긴다.
+ ///
///
- private static List Closure(NodeBase start)
+ private static List<(NodeBase Node, bool IsDeclared)> Closure(NodeBase start)
{
- var visited = new HashSet(NodeReferenceComparer.Instance) { start };
- var result = new List { start };
+ var index = new Dictionary(NodeReferenceComparer.Instance) { [start] = 0 };
+ var result = new List<(NodeBase Node, bool IsDeclared)> { (start, true) };
for (var i = 0; i < result.Count; i++)
{
- var n = result[i];
- foreach (var d in n.Dependents)
- {
- if (visited.Add(d)) result.Add(d);
- }
- foreach (var l in n.InvalidatorListeners)
- {
- if (visited.Add(l)) result.Add(l);
- }
- foreach (var s in n.Selected)
+ var n = result[i].Node;
+ foreach (var d in n.Dependents) Visit(d, false);
+ foreach (var l in n.InvalidatorListeners) Visit(l, true);
+ foreach (var s in n.Selected) Visit(s, true);
+ }
+ return result;
+
+ void Visit(NodeBase node, bool isDeclared)
+ {
+ if (index.TryGetValue(node, out var at))
{
- if (visited.Add(s)) result.Add(s);
+ if (isDeclared && !result[at].IsDeclared) result[at] = (node, true);
+ return;
}
+ index[node] = result.Count;
+ result.Add((node, isDeclared));
}
- return result;
}
}
diff --git a/src/GevSharp/GenApi/INode.cs b/src/GevSharp/GenApi/INode.cs
index 1db30b5..b8d19f8 100644
--- a/src/GevSharp/GenApi/INode.cs
+++ b/src/GevSharp/GenApi/INode.cs
@@ -67,7 +67,10 @@ public interface INode
ValueTask IsLockedAsync(CancellationToken ct = default);
ValueTask GetAccessModeAsync(CancellationToken ct = default);
- /// 이 노드와 이 노드에 의존하는 노드들의 캐시를 버린다.
+ ///
+ /// 이 노드와 이 노드에 의존하는 노드들의 캐시를 버린다. 이 노드의 값 사슬(pValue → … → 레지스터, 수식 노드의 값 pVariable 입력 포함)까지
+ /// 내려가므로 다음 읽기는 그 레지스터를 장치에서 다시 읽는다.
+ ///
void Invalidate();
}
@@ -139,7 +142,18 @@ public interface IEnumeration : INode
public interface ICommand : INode
{
ValueTask ExecuteAsync(CancellationToken ct = default);
- /// 실행이 끝났는지(레지스터가 CommandValue 에서 돌아왔는지). 폴링 정보가 없으면 항상 true.
+ ///
+ /// 실행이 끝났는지 — 이 라이브러리 고유의 규칙이며 표준 문서의 규칙과 대조하지 않았다.
+ /// Command 노드 자신에 PollingTime 이 있고 명령의 접근 모드로 pValue 를 읽을 수 있을 때만 pValue 를 장치에서 새로 읽어,
+ /// 명령 값에서 벗어났으면 true 다(자기 소거 비트로 본다). PollingTime 의 값(주기)은 쓰지 않고 있는지만 본다 — 폴링 간격과 시한은 호출자가 정한다.
+ ///
+ /// 그 밖에는 — PollingTime 이 없거나, pValue 가 없거나, 명령이 쓰기 전용이면(잠긴 쓰기 전용 포함) — 장치에 묻지 않고 항상 true 다.
+ /// 이때 true 는 "끝났다" 가 아니라 "이 라이브러리가 볼 수 있는 진행 중 표시가 없다" 는 뜻이라 완료 신호로 쓸 수 없다.
+ /// 끝나기를 기다려야 하는 명령(사용자 설정 불러오기 등)의 설명에 Command 수준 PollingTime 이 없으면, 장치 문서가 정한 상태 노드를
+ /// 읽거나 호출자가 정한 안정 시간을 기다린다.
+ ///
+ /// 되읽어야 하는데 명령이 구현되지 않았거나 가용하지 않으면 포트에 닿지 않고 .
+ ///
ValueTask IsDoneAsync(CancellationToken ct = default);
}
diff --git a/src/GevSharp/GenApi/Model/NodeDef.cs b/src/GevSharp/GenApi/Model/NodeDef.cs
index 6dfd924..4131189 100644
--- a/src/GevSharp/GenApi/Model/NodeDef.cs
+++ b/src/GevSharp/GenApi/Model/NodeDef.cs
@@ -92,8 +92,9 @@ public abstract record NodeDef
public bool IsDeprecated { get; init; }
///
- /// PollingTime(ms). 레지스터 노드에서는 읽기 캐시를 쓰지 말라는 뜻(장치가 값을 스스로 바꾼다), Command 에서는 완료 폴링 주기.
- /// 어느 요소에나 올 수 있어 공통 필드로 둔다. 없으면 null.
+ /// PollingTime(ms). 런타임은 값이 아니라 있는지만 본다 — 레지스터 노드에서는 읽기 캐시를 쓰지 말라는 뜻(장치가 값을 스스로 바꾼다),
+ /// Command 에서는 IsDone 이 pValue 를 되읽는다는 뜻(자기 소거 비트로 본다 — 이 라이브러리 고유 규칙). 주기 자체는 어디서도 쓰지 않으며
+ /// 폴링 간격은 호출자가 정한다. 어느 요소에나 올 수 있어 공통 필드로 둔다. 없으면 null.
///
public long? PollingTimeMs { get; init; }
diff --git a/src/GevSharp/GenApi/Runtime/FloatNodes.cs b/src/GevSharp/GenApi/Runtime/FloatNodes.cs
index a99589d..70f7ef8 100644
--- a/src/GevSharp/GenApi/Runtime/FloatNodes.cs
+++ b/src/GevSharp/GenApi/Runtime/FloatNodes.cs
@@ -365,6 +365,8 @@ protected override void BindCore(NodeBinder binder)
_formula = _scope.Parse(_def.Formula, "Formula");
}
+ internal override void CollectFormulaInputs(List into) => _scope.CollectVariableTargets(into);
+
internal override async ValueTask ReadDoubleAsync(CancellationToken ct)
=> (await _scope.EvaluateAsync(_formula, null, default, ct).ConfigureAwait(false)).AsDouble;
@@ -405,6 +407,8 @@ protected override void BindCore(NodeBinder binder)
_from = _scope.Parse(_def.FormulaFrom, "FormulaFrom");
}
+ internal override void CollectFormulaInputs(List into) => _scope.CollectVariableTargets(into);
+
internal override async ValueTask ReadDoubleAsync(CancellationToken ct)
{
var to = await _pValue.ReadValueAsync(ct).ConfigureAwait(false);
diff --git a/src/GevSharp/GenApi/Runtime/FormulaScope.cs b/src/GevSharp/GenApi/Runtime/FormulaScope.cs
index 1f216dc..500b297 100644
--- a/src/GevSharp/GenApi/Runtime/FormulaScope.cs
+++ b/src/GevSharp/GenApi/Runtime/FormulaScope.cs
@@ -80,6 +80,19 @@ public Formula Parse(string text, string label)
}
}
+ ///
+ /// 값으로 읽는 pVariable 대상 노드 전부(접미사 없음·.Value) — 무효화가 수식 노드 아래로 내려갈 때 쓴다.
+ /// .Entry. 는 바인딩 때 굳은 상수라 넣지 않는다. .Min/.Max/.Inc 도 넣지 않는다 — 그 변수는 대상의 값이 아니라
+ /// 한계를 읽으므로 대상의 값 사슬을 버려도 새로워지지 않는다(한계 간선은 pMin/pMax 처럼 값 사슬 밖이다).
+ ///
+ public void CollectVariableTargets(List into)
+ {
+ foreach (var v in _variables.Values)
+ {
+ if (v.Suffix == VarSuffix.Value) into.Add(v.Node);
+ }
+ }
+
/// 수식을 평가한다. extraName 은 Converter 의 FROM/TO 처럼 호출자가 값을 주는 변수.
public ValueTask EvaluateAsync(Formula formula, string? extraName, GenApiValue extraValue, CancellationToken ct)
=> formula.EvaluateAsync(name => ResolveAsync(name, extraName, extraValue, 0, ct), _mode, ct);
diff --git a/src/GevSharp/GenApi/Runtime/IntegerNodes.cs b/src/GevSharp/GenApi/Runtime/IntegerNodes.cs
index 4ab32a7..75c3d1a 100644
--- a/src/GevSharp/GenApi/Runtime/IntegerNodes.cs
+++ b/src/GevSharp/GenApi/Runtime/IntegerNodes.cs
@@ -457,6 +457,8 @@ protected override void BindCore(NodeBinder binder)
_formula = _scope.Parse(_def.Formula, "Formula");
}
+ internal override void CollectFormulaInputs(List into) => _scope.CollectVariableTargets(into);
+
internal override async ValueTask ReadInt64Async(CancellationToken ct)
=> NumericCodec.ToInt64(await _scope.EvaluateAsync(_formula, null, default, ct).ConfigureAwait(false), Name);
@@ -496,6 +498,8 @@ protected override void BindCore(NodeBinder binder)
_from = _scope.Parse(_def.FormulaFrom, "FormulaFrom");
}
+ internal override void CollectFormulaInputs(List into) => _scope.CollectVariableTargets(into);
+
internal override async ValueTask ReadInt64Async(CancellationToken ct)
{
var to = await _pValue.ReadValueAsync(ct).ConfigureAwait(false);
diff --git a/src/GevSharp/GenApi/Runtime/NodeBase.cs b/src/GevSharp/GenApi/Runtime/NodeBase.cs
index ef82e3d..3f93adf 100644
--- a/src/GevSharp/GenApi/Runtime/NodeBase.cs
+++ b/src/GevSharp/GenApi/Runtime/NodeBase.cs
@@ -118,6 +118,13 @@ internal virtual void CollectValueTargets(List into)
if (ValueTarget is { } t) into.Add(t);
}
+ ///
+ /// 수식 노드(SwissKnife/IntSwissKnife/Converter/IntConverter)가 값을 계산하려고 읽는 pVariable 대상들 — 수식 노드가 아니면 없다.
+ /// 와 따로 두는 까닭: 이 노드가 스스로 낡았을 때만 입력 전부를 버리고, 입력 하나가 낡아
+ /// 의존으로 닿았을 때는 나머지 입력을 그대로 믿는다().
+ ///
+ internal virtual void CollectFormulaInputs(List into) { }
+
/// 지금 값을 위임하는 노드 — pIndex 선택처럼 읽어야 정해지는 경우가 있어 비동기다. 값 출처가 없으면 null(던지지 않는다).
internal virtual ValueTask GetAccessTargetAsync(CancellationToken ct) => new(ValueTarget);
@@ -217,6 +224,19 @@ internal async ValueTask EnsureReadableAsync(CancellationToken ct)
throw new GenApiException($"Node '{Name}' cannot be read: {Reason(mode, false, detail)}.", Name);
}
+ ///
+ /// 값을 되읽어 확인할 수 있는지 — 읽을 수 있으면 참, 쓰기 전용이면 거짓. 잠금은 쓰기만 막으므로 읽기 판정에 끼지 않는다:
+ /// 잠긴 쓰기 전용 노드는 접근 모드가 NotAvailable 로 합성되지만 여기서는 쓰기 전용(거짓)이다.
+ /// 구현되지 않았거나 가용하지 않으면(값 출처 없음 포함) 노드 이름과 사유를 담아 — what 은 메시지의 동작 이름.
+ ///
+ internal async ValueTask CanReadBackAsync(string what, CancellationToken ct)
+ {
+ var (mode, isLockDegraded, detail) = await ComputeAccessAsync(ct).ConfigureAwait(false);
+ if (CanRead(mode)) return true;
+ if (mode == AccessMode.WriteOnly || isLockDegraded) return false;
+ throw new GenApiException($"Node '{Name}' cannot be {what}: {Reason(mode, false, detail)}.", Name);
+ }
+
/// 쓸 수 없으면 노드 이름과 사유("not implemented"/"not available"/"locked"/"read-only"/값 출처 없음)를 담아 던진다.
internal async ValueTask EnsureWritableAsync(CancellationToken ct)
{
@@ -267,8 +287,14 @@ private static async ValueTask EvalPredicateAsync(NodeBase predicate, Canc
/// 노드 자신과 값 사슬(pValue/pValueDefault/pValueIndexed → … → 레지스터)의 캐시를 버린다 — 의존 노드로의 전파는 없다.
/// stopAt 에 닿으면 그 아래로는 내려가지 않는다(방금 쓰인 노드 — 그 캐시는 쓰기 정책이 정했다). 순환은 바인딩에서 막히지만
/// 방문 집합으로 한 번 더 지킨다.
+ ///
+ /// throughFormulas 가 참이면 수식 노드의 pVariable 입력()까지 내려간다 — 이 노드 자체가 낡았다고
+ /// 선언된 경우(무효화 대상, pInvalidator 청취자, pSelected 대상)다. 래치 뒤의 두 레지스터를 IntSwissKnife 로 합쳐 읽는 값처럼
+ /// 값이 수식 입력에서만 오는 노드는 그래야 새로 읽힌다. 입력 하나가 낡아 의존으로 닿은 노드는 거짓으로 부른다 —
+ /// 낡은 입력은 따로 버려지고 나머지 입력은 믿을 수 있다.
+ ///
///
- internal static void DropCacheChain(NodeBase? node, NodeBase? stopAt = null)
+ internal static void DropCacheChain(NodeBase? node, NodeBase? stopAt = null, bool throughFormulas = true)
{
if (node is null || ReferenceEquals(node, stopAt)) return;
var queue = new List { node };
@@ -280,6 +306,7 @@ internal static void DropCacheChain(NodeBase? node, NodeBase? stopAt = null)
n.DropOwnCache();
targets.Clear();
n.CollectValueTargets(targets);
+ if (throughFormulas) n.CollectFormulaInputs(targets);
foreach (var t in targets)
{
if (!ReferenceEquals(t, stopAt) && visited.Add(t)) queue.Add(t);
diff --git a/src/GevSharp/GenApi/Runtime/OtherNodes.cs b/src/GevSharp/GenApi/Runtime/OtherNodes.cs
index 61d2465..e11d5c4 100644
--- a/src/GevSharp/GenApi/Runtime/OtherNodes.cs
+++ b/src/GevSharp/GenApi/Runtime/OtherNodes.cs
@@ -418,7 +418,9 @@ private string EntryNames()
///
/// <Command> — 실행은 CommandValue(또는 pCommandValue 값, 둘 다 없으면 1)를 pValue 에 쓰는 것. pValue 가 없으면 리터럴 Value 자리의
/// 호스트 측 변수에 남는다(Integer/Boolean 의 리터럴 Value 와 같은 규칙).
-/// PollingTime 이 있으면 가 pValue 를 새로 읽어 명령 값에서 돌아왔는지 본다(자기 소거 비트); 없으면 항상 완료.
+/// 는 이 라이브러리 고유 규칙이다(표준 문서와 대조하지 않았다): 이 노드 자신에 PollingTime 이 있고 접근 모드로
+/// pValue 를 읽을 수 있을 때만 새로 읽어 명령 값에서 벗어났는지 본다(자기 소거 비트로 본다). 그 밖에는 장치에 묻지 않고 참 — 완료 신호가 아니다.
+/// PollingTime 의 값은 쓰지 않고 있는지만 본다.
///
internal sealed class CommandNode : NodeBase, ICommand
{
@@ -469,6 +471,9 @@ public async ValueTask ExecuteAsync(CancellationToken ct = default)
public async ValueTask IsDoneAsync(CancellationToken ct = default)
{
if (_def.PollingTimeMs is null || _pValue is null) return true;
+ // 되읽기는 이 명령의 접근 모드(pValue 의 모드·ImposedAccessMode·술어를 합친 것)를 따른다 — 아래 내부 값 경로는 검사 없이 포트를 부른다.
+ // 쓰기 전용이면 완료를 볼 길이 없어 PollingTime 이 없을 때와 같이 참, 구현·가용하지 않으면 사유를 담아 던진다. 어느 쪽도 포트에 닿지 않는다.
+ if (!await CanReadBackAsync("polled for completion", ct).ConfigureAwait(false)) return true;
DropCacheChain(_pValue);
var current = await ReadInt64FromAsync(_pValue, ct).ConfigureAwait(false);
var command = await CommandValueAsync(ct).ConfigureAwait(false);
diff --git a/src/GevSharp/GevDevice.Access.cs b/src/GevSharp/GevDevice.Access.cs
index 654a57d..068bf59 100644
--- a/src/GevSharp/GevDevice.Access.cs
+++ b/src/GevSharp/GevDevice.Access.cs
@@ -223,7 +223,11 @@ private static void ThrowIfRangeOverflows(uint addr, int length)
// ------------------------------------------------------------------ IGevPort
- /// GenApi 포트 읽기. 4바이트 정렬 4바이트는 READREG, 나머지는 READMEM. 32비트를 넘는 주소는 .
+ ///
+ /// GenApi 포트 읽기. 4바이트 정렬 4바이트는 READREG, 나머지는 READMEM.
+ /// 32비트를 넘는 주소는 던지지 않고 하위 32비트로 좁혀 읽는다(주소마다 경고 한 번 — ).
+ /// 좁힌 범위의 끝이 32비트 공간을 넘을 때만 아무것도 보내기 전에 .
+ ///
async ValueTask IGevPort.ReadAsync(ulong address, Memory buffer, CancellationToken ct)
{
var addr = ToGvcpAddress(address, buffer.Length);
@@ -236,7 +240,7 @@ async ValueTask IGevPort.ReadAsync(ulong address, Memory buffer, Cancellat
await ReadMemAsync(addr, buffer, ct).ConfigureAwait(false);
}
- /// GenApi 포트 쓰기. 4바이트 정렬 4바이트는 WRITEREG, 나머지는 WRITEMEM.
+ /// GenApi 포트 쓰기. 4바이트 정렬 4바이트는 WRITEREG, 나머지는 WRITEMEM. 32비트를 넘는 주소는 읽기와 같이 좁힌다.
async ValueTask IGevPort.WriteAsync(ulong address, ReadOnlyMemory data, CancellationToken ct)
{
var addr = ToGvcpAddress(address, data.Length);
diff --git a/src/GevSharp/GevDevice.GenApi.cs b/src/GevSharp/GevDevice.GenApi.cs
index e18f7f2..34488dc 100644
--- a/src/GevSharp/GevDevice.GenApi.cs
+++ b/src/GevSharp/GevDevice.GenApi.cs
@@ -10,6 +10,7 @@ public sealed partial class GevDevice
///
/// 카메라 XML 을 받아() 이 장치를 포트로 바인딩한 노드맵을 만든다. 세션 동안 한 번만 만들고 캐시한다.
/// 레지스터를 노드맵 밖에서 직접 썼다면 로 캐시를 버린다.
+ /// XML 적재 실패는 와 같은 예외다(장치 상실은 그 형 그대로), XML 해석·바인딩 실패는 .
///
public async Task GetNodeMapAsync(CancellationToken ct = default)
{
@@ -39,14 +40,21 @@ public async Task GetNodeMapAsync(CancellationToken ct = default)
/// 예를 들어 AcquisitionStart 의 pIsLocked 가 TLParamsLocked = 0 인 장치에서는 1 을 쓰기 전까지
/// 그 커맨드가 잠긴 WO, 즉 접근 불가(NA)로 보여 실행할 수 없다.
/// 순서: 스트림 StartAsync → 이 메서드에 true → AcquisitionStart … AcquisitionStop → 이 메서드에 false → 스트림 StopAsync.
- /// 노드가 없는 장치에서는 아무것도 하지 않고 false 를 돌려준다(그런 장치는 이 잠금을 쓰지 않는다).
+ /// 값을 썼으면 true. false 는 아무것도 쓰지 않았다는 뜻이고 두 경우다 — 노드가 없는 장치(그런 장치는 이 잠금을 쓰지 않는다, Debug 로그)와,
+ /// 같은 이름의 노드가 정수 노드가 아닌 기술(쓸 방법을 모른다 — 그 노드에 걸린 잠금은 그대로 남으므로 Warn 로그에 노드 종류를 적는다).
+ /// 예외로 올리지 않는 것은 앞의 경우가 정상이라서다. 뒤의 경우를 가려야 하면 로 종류를 본다.
///
public async Task SetTlParamsLockedAsync(bool locked, CancellationToken ct = default)
{
var nodes = await GetNodeMapAsync(ct).ConfigureAwait(false);
- if (nodes.GetNode(TlParamsLockedNode) is not GenApi.IInteger node)
+ var found = nodes.GetNode(TlParamsLockedNode);
+ if (found is not GenApi.IInteger node)
{
- GevLog.Debug(LogSrc, $"{TlParamsLockedNode} is not in the node map of {Address}; transport-layer locking is not used by this device");
+ // 두 경우를 한 문구로 적지 않는다 — 노드가 있는데 "없다" 고 적히면, 획득 커맨드가 잠긴 채 남은 이유를 로그에서 찾을 수 없다.
+ if (found is null)
+ GevLog.Debug(LogSrc, $"{TlParamsLockedNode} is not in the node map of {Address}; transport-layer locking is not used by this device");
+ else
+ GevLog.Warn(LogSrc, $"{TlParamsLockedNode} on {Address} is a {found.Kind} node, not an integer; nothing was written, so features the description gates on it keep their current lock state");
return false;
}
await node.SetAsync(locked ? 1 : 0, ct).ConfigureAwait(false);
diff --git a/src/GevSharp/GevDevice.Stream.cs b/src/GevSharp/GevDevice.Stream.cs
index 6d441e6..130d928 100644
--- a/src/GevSharp/GevDevice.Stream.cs
+++ b/src/GevSharp/GevDevice.Stream.cs
@@ -19,7 +19,9 @@ public Task OpenStreamAsync(int streamChannel, GevStreamOpt? opt = nu
throw new GevControlLostException("a read-only session cannot configure a stream channel; open the device with Control or Exclusive access");
}
ct.ThrowIfCancellationRequested();
- var stream = new GevStream(this, Gvcp, LocalAddress, opt, streamChannel, Address);
+ // 정지의 장치 전송 끄기는 닫기의 CCP 해제와 같은 고정 예산을 받는다 — 스트림은 이 장치의 응답 창을 모른다.
+ // 응답 창은 채널이 열 때 검증해 사본으로 쥔 값에서 읽는다. 호출자의 옵션 객체는 열고 난 뒤에도 바뀔 수 있다.
+ var stream = new GevStream(this, Gvcp, LocalAddress, opt, streamChannel, Address, ShutdownWriteBudgetMs(Gvcp.Opt.TimeoutMs));
return Task.FromResult(stream);
}
}
diff --git a/src/GevSharp/GevDevice.Xml.cs b/src/GevSharp/GevDevice.Xml.cs
index 806b4bf..8a4a754 100644
--- a/src/GevSharp/GevDevice.Xml.cs
+++ b/src/GevSharp/GevDevice.Xml.cs
@@ -10,6 +10,14 @@ public sealed partial class GevDevice
///
/// 카메라 XML 을 가져온다(First URL → Second URL 폴백, Local:/File:/http 3경로, ZIP 해제).
/// 한 번 받으면 세션 동안 캐시한다. 디스크 캐시는 가 있을 때만.
+ ///
+ /// 적재 중 장치를 잃으면(, 응답 없는 ,
+ /// 해제된 장치의 ) Second URL 로 넘어가지 않고 그 예외를 그대로 던진다 — 다시 연결할 일이다.
+ /// 여기서 감싸지 않은 채 나오는 은 언제나 이 경우다. 상대가 답은 한 시한 초과(http 내려받기의 시한 초과,
+ /// 장치가 PENDING_ACK 로 답한 뒤 연장 안에 못 끝낸 읽기)는 Second URL 로 넘어가고, 두 URL 이 모두 그렇게 실패해도
+ /// 으로 감싸 낸다. 그 밖에 두 URL 이 같은 종류로 실패하면 그 예외를, 다른 종류로 실패하면 두 사유를 담은
+ /// 을 던진다(규칙 전체는 ).
+ ///
///
public async Task GetXmlAsync(CancellationToken ct = default)
{
diff --git a/src/GevSharp/GevDevice.cs b/src/GevSharp/GevDevice.cs
index 9b138ed..15ebd61 100644
--- a/src/GevSharp/GevDevice.cs
+++ b/src/GevSharp/GevDevice.cs
@@ -8,6 +8,13 @@ namespace GevSharp;
/// 장치 제어 세션 — GVCP 채널, CCP 제어권, 하트비트, 레지스터/메모리 접근, .
/// 파티션: 이 파일(열기·하트비트·닫기), GevDevice.Access.cs(레지스터/메모리/포트).
/// XML(GetXmlAsync)·노드맵(GetNodeMapAsync)·스트림(OpenStreamAsync)은 각 모듈이 partial 파티션으로 덧붙인다.
+///
+/// 오류 계약: 장치에 닿는 조작은 계열(응답 없음 , 장치 거절
+/// , 제어권 상실 )과 함께 을 던진다 —
+/// 뒤의 모든 장치 접근(앞서 받아 둔 노드맵의 노드 조작도 포트가 이 장치라 같다. 이미 받아 둔 XML·노드맵을
+/// 돌려주는 호출만은 캐시에서 답한다), 그리고 닫기와 겹쳐 채널에 늦게 닿은 요청. 취소는 .
+/// "라이브러리가 낸 실패 전부" 를 잡으려면 GevException 과 ObjectDisposedException 을 함께 잡는다.
+///
///
public sealed partial class GevDevice : IGevPort, IAsyncDisposable
{
@@ -25,6 +32,13 @@ public sealed partial class GevDevice : IGevPort, IAsyncDisposable
/// 닫을 때 CCP = 0 쓰기에 주는 최대 시간 — 채널 재시도 예산 전부를 닫기에 쓰지 않는다.
internal const int CcpReleaseMaxMs = 2000;
+ ///
+ /// 끝내는 자리의 쓰기(장치 닫기의 CCP = 0, 스트림 정지의 SCP = 0·SCDA = 0)에 주는 고정 예산 — 응답 창 두 개(재전송 한 번의 여유),
+ /// 많아야 . 호출자의 토큰에도 재시도 횟수에도 기대지 않는다: 채널 예산에 기대면 말없는 장치 앞에서 정리가
+ /// (1 + GvcpRetries) × 응답 창만큼 붙들리고, 재시도가 끝없으면(GvcpRetries = int.MaxValue) 돌아오지 않는다.
+ ///
+ internal static int ShutdownWriteBudgetMs(int gvcpTimeoutMs) => (int)Math.Min((long)gvcpTimeoutMs * 2, CcpReleaseMaxMs);
+
private const int StateOpening = 0;
private const int StateOpen = 1;
private const int StateControlLost = 2;
@@ -54,6 +68,7 @@ private GevDevice(IPEndPoint device, IPAddress localAddress, GevDeviceOpt opt)
// 장치가 열리지 못한다. 채널 기본값으로 열고, 하트비트를 시작하기 직전에 InitAsync 가 실제 값으로 좁힌다.
MaxPendingAckWaitMs = opt.MaxPendingAckWaitMs ?? GvcpChannelOpt.DefaultMaxPendingAckWaitMs,
});
+ Gvcp.OnClosed = OnChannelClosed;
_logSrc = $"{LogSrc} {Address}";
}
@@ -64,13 +79,19 @@ private GevDevice(IPEndPoint device, IPAddress localAddress, GevDeviceOpt opt)
/// GVCP 소켓이 묶인 호스트 주소. 스트림의 SCDA 로도 쓴다.
public IPAddress LocalAddress { get; }
public GevAccessMode AccessMode => _opt.AccessMode;
- /// 열려 있고 제어권을 잃지 않았다.
+ ///
+ /// 열려 있고 제어권을 잃지 않았다. 뒤, 그리고 제어권을 잃은 뒤(하트비트 연속 실패, CCP 가 풀림,
+ /// 제어 채널 가 세션보다 먼저 닫힘)에는 false — 제어 채널이 닫히면 하트비트를 기다리지 않고 그 자리에서 바뀐다.
+ ///
public bool IsOpen => Volatile.Read(ref _state) == StateOpen;
/// GVBS 0x0934.
public uint GvcpCapability { get; private set; }
/// GVBS 0x093C/0x0940 (Hz). 읽지 못하면 0.
public ulong TimestampTickFrequency { get; private set; }
- /// 장치가 실제로 적용한 하트비트 타임아웃(GVBS 0x0938 을 다시 읽은 값).
+ ///
+ /// 장치가 실제로 적용한 하트비트 타임아웃(GVBS 0x0938 을 다시 읽은 값). 레지스터는 부호 없는 32비트라
+ /// int 에 들어가지 않는 값(2^31 ms 이상)은 로 포화한다 — 음수가 되는 일은 없다.
+ ///
public int DeviceHeartbeatTimeoutMs { get; private set; }
/// 하트비트 주기. 읽기 전용 세션은 0.
public int HeartbeatPeriodMs { get; private set; }
@@ -84,6 +105,14 @@ private GevDevice(IPEndPoint device, IPAddress localAddress, GevDeviceOpt opt)
// ------------------------------------------------------------------ open
/// 탐색 결과로 연다. 로컬 주소는 옵션 → 응답을 들은 인터페이스 순으로 정한다.
+ /// 가 null.
+ /// 의 값이 범위를 벗어났다.
+ ///
+ /// 장치 주소가 IPv4 가 아니거나, 쓸 로컬 주소가 없는데(옵션에도 탐색 결과에도) 장치로 나가는 로컬 주소를 정할 수 없거나, 열기 순서에서
+ /// 장치와 주고받다 실패했다 — 그 실패의 하위 형식은 와 같다.
+ ///
+ /// GVCP 소켓을 로컬 주소에 묶지 못했다(이 경우만 감싸지 않고 그대로 나온다).
+ /// 가 취소됐다.
public static Task OpenAsync(GevDeviceInfo info, GevDeviceOpt? opt = null, CancellationToken ct = default)
{
if (info is null) throw new ArgumentNullException(nameof(info));
@@ -92,16 +121,43 @@ public static Task OpenAsync(GevDeviceInfo info, GevDeviceOpt? opt =
}
/// 주소로 연다. 로컬 주소는 옵션 → 같은 서브넷 인터페이스 → OS 라우팅 순으로 정한다.
+ /// 가 null.
+ /// 의 값이 범위를 벗어났다.
+ ///
+ /// IPv4 주소가 아니거나, 옵션에 로컬 주소가 없는데 장치로 나가는 로컬 주소를 정할 수 없거나, 열기 순서에서 장치와 주고받다 실패했다 —
+ /// 그 실패의 하위 형식은 와 같다.
+ ///
+ /// GVCP 소켓을 로컬 주소에 묶지 못했다(이 경우만 감싸지 않고 그대로 나온다).
+ /// 가 취소됐다.
public static Task OpenAsync(IPAddress address, GevDeviceOpt? opt = null, CancellationToken ct = default)
{
if (address is null) throw new ArgumentNullException(nameof(address));
return OpenCoreAsync(new IPEndPoint(address, GvcpConst.Port), opt?.LocalAddress, opt, ct);
}
- /// 포트를 지정해 연다 — 표준 포트가 아닌 시뮬레이터용.
- internal static Task OpenAsync(IPEndPoint device, GevDeviceOpt? opt = null, CancellationToken ct = default)
+ ///
+ /// 주소와 GVCP 포트로 연다 — 표준 포트(3956)가 아닌 곳에서 답하는 장치용: 루프백의 시뮬레이터, 포트를 옮겨 둔 NAT·포워딩 뒤의 장치 등.
+ /// 로컬 주소는 옵션 → 같은 서브넷 인터페이스 → OS 라우팅 순으로 정한다. IPv4 만 받는다.
+ /// 이 포트는 제어 채널(레지스터 접근·하트비트·리센드 요청)에만 쓰인다 — 스트림은 장치가 자기 설정대로 보내는 곳에서 받는다.
+ /// 세션은 의 사본을 쥔다 — 연 뒤에 그 객체를 다른 장치에 다시 써도 이 세션은 처음 연 장치에 묶여 있다.
+ ///
+ /// 가 null.
+ /// 의 포트가 0 이거나, 의 값이 범위를 벗어났다.
+ ///
+ /// IPv4 끝점이 아니거나, 옵션에 로컬 주소가 없는데 장치로 나가는 로컬 주소를 정할 수 없거나, 보내기가 소켓 오류로 실패했다.
+ /// 열기 순서(부트스트랩 읽기 → CCP → 하트비트 타임아웃)에서 장치와 주고받다 난 실패는 하위 형식으로 온다 — 응답 없음
+ /// , 장치 거절 , 다른 애플리케이션이 제어권을 쥐고 있음
+ /// ( 가 아닐 때). 실패한 열기는 세션을 닫으며, CCP 쓰기를
+ /// 내보낸 뒤였으면 짧은 예산 안에서 놓아 주려 한다(못 놓으면 장치가 자기 하트비트 타임아웃으로 푼다).
+ ///
+ ///
+ /// GVCP 소켓을 로컬 주소에 묶지 못했다 — 옵션의 LocalAddress 가 이 호스트의 IPv4 주소가 아닐 때 등. 이 경우만 감싸지 않고 그대로 나온다.
+ ///
+ /// 가 취소됐다.
+ public static Task OpenAsync(IPEndPoint device, GevDeviceOpt? opt = null, CancellationToken ct = default)
{
if (device is null) throw new ArgumentNullException(nameof(device));
+ if (device.Port == 0) throw new ArgumentOutOfRangeException(nameof(device), "The GVCP port of the device end point must be 1..65535.");
return OpenCoreAsync(device, opt?.LocalAddress, opt, ct);
}
@@ -118,8 +174,10 @@ internal static Task OpenAsync(IPEndPoint device, GevDeviceOpt? opt =
///
internal static int AutoPendingAckWaitMs(int deviceTimeoutMs, int periodMs, int gvcpTimeoutMs)
{
- var budgetMs = deviceTimeoutMs - periodMs - 2 * gvcpTimeoutMs;
- if (budgetMs >= gvcpTimeoutMs) return budgetMs;
+ // long 으로 센다 — 응답 창이 int.MaxValue / 2 를 넘으면 2 × 응답 창이 int 로는 음수로 감겨, 여유가 없는 설정이
+ // 경고 없이 수십억 ms 의 상한을 얻는다. 결과가 응답 창 이상이면 deviceTimeoutMs 보다 작으므로 int 로 되돌려도 안전하다.
+ var budgetMs = (long)deviceTimeoutMs - periodMs - 2L * gvcpTimeoutMs;
+ if (budgetMs >= gvcpTimeoutMs) return (int)budgetMs;
GevLog.Warn(LogSrc, $"GVCP response window {gvcpTimeoutMs} ms leaves no PENDING_ACK budget inside the device heartbeat timeout {deviceTimeoutMs} ms (heartbeat period {periodMs} ms); capping the PENDING_ACK wait at {gvcpTimeoutMs} ms, control may drop on a slow command");
return gvcpTimeoutMs;
}
@@ -151,7 +209,7 @@ private async Task InitAsync(CancellationToken ct)
{
_info = await GevDeviceInfo.ReadFromDeviceAsync(Gvcp, LocalAddress, ct).ConfigureAwait(false);
GvcpCapability = await ReadRegCoreAsync(GvbsAddr.GvcpCapability, ct).ConfigureAwait(false);
- DeviceHeartbeatTimeoutMs = (int)await ReadRegCoreAsync(GvbsAddr.HeartbeatTimeout, ct).ConfigureAwait(false);
+ DeviceHeartbeatTimeoutMs = SaturateToMs(await ReadRegCoreAsync(GvbsAddr.HeartbeatTimeout, ct).ConfigureAwait(false));
TimestampTickFrequency = await ReadTickFrequencyAsync(ct).ConfigureAwait(false);
GevLog.Info(_logSrc, $"opened {_info.Manufacturer} {_info.Model} [{_info.SerialNumber}] via {LocalAddress} (spec {_info.SpecMajor}.{_info.SpecMinor}, cap 0x{GvcpCapability:X8}, tick {TimestampTickFrequency} Hz)");
@@ -187,9 +245,13 @@ private async Task InitAsync(CancellationToken ct)
{
GevLog.Warn(_logSrc, $"device rejected heartbeat timeout {_opt.HeartbeatTimeoutMs} ms ({GvcpConst.StatusName(ex.Status)}); keeping the device value");
}
- DeviceHeartbeatTimeoutMs = (int)await ReadRegCoreAsync(GvbsAddr.HeartbeatTimeout, ct).ConfigureAwait(false);
+ var rawTimeoutMs = await ReadRegCoreAsync(GvbsAddr.HeartbeatTimeout, ct).ConfigureAwait(false);
+ DeviceHeartbeatTimeoutMs = SaturateToMs(rawTimeoutMs);
- var effectiveTimeout = DeviceHeartbeatTimeoutMs > 0 ? DeviceHeartbeatTimeoutMs : _opt.HeartbeatTimeoutMs;
+ // 주기와 PENDING_ACK 상한을 끌어낼 근거로는 1..int.MaxValue 로 읽힌 값만 쓴다. 0 이나 int 에 들어가지 않는 값이면
+ // 요청한 타임아웃으로 계산한다 — 포화된 값으로 끌어내면 하트비트가 며칠에 한 번이 되는데, 그 되읽기를 믿을 근거가 없고
+ // 너무 드물게 치면 잃는 것은 제어권, 너무 자주 치면 잃는 것은 패킷 몇 개라 요청값 쪽이 안전하다.
+ var effectiveTimeout = rawTimeoutMs is > 0 and <= int.MaxValue ? (int)rawTimeoutMs : _opt.HeartbeatTimeoutMs;
HeartbeatPeriodMs = _opt.HeartbeatPeriodMs ?? Math.Max(1, effectiveTimeout / 3);
if (HeartbeatPeriodMs >= effectiveTimeout)
GevLog.Warn(_logSrc, $"heartbeat period {HeartbeatPeriodMs} ms is not shorter than the device timeout {effectiveTimeout} ms; control may drop");
@@ -201,6 +263,12 @@ private async Task InitAsync(CancellationToken ct)
GevLog.Debug(_logSrc, $"control acquired (CCP 0x{ccp:X}), heartbeat every {HeartbeatPeriodMs} ms, device timeout {DeviceHeartbeatTimeoutMs} ms");
}
+ ///
+ /// 부호 없는 32비트 ms 레지스터 값을 int 로 옮긴다. int 에 들어가지 않는 값은 로 포화한다 —
+ /// 그냥 캐스트하면 0xFFFFFFFF 가 -1()이 되어, 그 값을 대기 시간으로 쓰는 쪽이 영영 기다린다.
+ ///
+ private static int SaturateToMs(uint raw) => raw > int.MaxValue ? int.MaxValue : (int)raw;
+
private async Task ReadTickFrequencyAsync(CancellationToken ct)
{
try
@@ -233,6 +301,8 @@ private async Task HeartbeatLoopAsync(int periodMs, CancellationToken ct)
while (!ct.IsCancellationRequested)
{
await Task.Delay(periodMs, ct).ConfigureAwait(false);
+ // 다른 길(제어 채널이 닫힘)로 이미 상실이 났으면 더 보낼 곳이 없다 — 닫힌 채널에 세 번 실패하며 경고를 쌓지 않는다.
+ if (Volatile.Read(ref _state) != StateOpen) return;
uint ccp;
try
@@ -304,6 +374,21 @@ private string ReleaseReason(uint ccp, long sinceMs, int periodMs)
return $"{head} although the last heartbeat reached the device only {sinceMs} ms ago (device timeout {timeout} ms): another application released or took the channel, or the device restarted";
}
+ ///
+ /// 제어 채널이 세션보다 먼저 닫혔다 — 수신 소켓이 회복 불가로 채널을 스스로 닫았거나, 누군가 를 직접 닫았다.
+ /// 열린 세션이면 그 자리에서 제어권 상실로 넘긴다. 하트비트가 세 번 실패하기를 기다리면 그동안 은 true 인데
+ /// 모든 조작이 ObjectDisposedException 으로 끝나고, 하트비트가 없는 읽기 전용 세션은 그 상태에서 영영 벗어나지 못한다.
+ /// 세션이 스스로 닫는 중(상태가 이미 Disposed)이거나 이미 잃었으면 아무것도 하지 않는다. 채널을 닫는 스레드에서 불린다.
+ ///
+ private void OnChannelClosed(Exception? cause)
+ {
+ if (Volatile.Read(ref _state) != StateOpen) return;
+ var reason = cause is null
+ ? "the GVCP control channel was closed while the device was open"
+ : $"the GVCP control channel closed itself: {cause.Message}";
+ OnControlLost(cause ?? new GevException(reason), reason);
+ }
+
///
/// 상태를 ControlLost 로 바꾸고 이벤트를 스레드 풀에서 올린다. 하트비트 태스크 안에서 직접 부르면
/// 핸들러가 를 기다릴 때 그 태스크 자신을 기다리게 되어 멈춘다 — 그래서 분리한다.
@@ -349,7 +434,10 @@ private void ThrowIfClosed()
}
}
- /// 하트비트를 멈추고, 제어 중이면 CCP = 0 을 써서 놓고, 채널을 닫는다. 몇 번 불러도 안전하다.
+ ///
+ /// 하트비트를 멈추고, 제어 중이면 CCP = 0 을 써서 놓고, 채널을 닫는다. 몇 번 불러도 안전하다.
+ /// 그 뒤 장치에 닿는 조작은 전부 이다( 이 아니다).
+ ///
public async ValueTask DisposeAsync()
{
var previous = Interlocked.Exchange(ref _state, StateDisposed);
@@ -374,7 +462,8 @@ public async ValueTask DisposeAsync()
{
// 닫기는 오래 붙들지 않는다 — 채널의 재시도 예산 전부가 아니라 짧은 고정 예산만 준다.
// 놓지 못해도 장치는 자기 하트비트 타임아웃으로 알아서 푼다.
- var releaseBudgetMs = (int)Math.Min((long)_opt.GvcpTimeoutMs * 2, CcpReleaseMaxMs);
+ // 응답 창은 채널이 쥔 사본에서 — 호출자의 옵션 객체는 열고 난 뒤에도 바뀔 수 있다(0 으로 바꾸면 해제를 시도조차 안 하게 된다).
+ var releaseBudgetMs = ShutdownWriteBudgetMs(Gvcp.Opt.TimeoutMs);
using var releaseCts = new CancellationTokenSource(releaseBudgetMs);
try
{
diff --git a/src/GevSharp/GevDeviceOpt.cs b/src/GevSharp/GevDeviceOpt.cs
index 5d80601..5b10dc0 100644
--- a/src/GevSharp/GevDeviceOpt.cs
+++ b/src/GevSharp/GevDeviceOpt.cs
@@ -23,7 +23,10 @@ public sealed class GevDeviceOpt
public int GvcpRetries { get; set; } = 3;
/// 제어권을 잡을 때 GVBS 0x0938 에 쓰는 장치 쪽 하트비트 타임아웃.
public int HeartbeatTimeoutMs { get; set; } = 3000;
- /// 하트비트(CCP 읽기) 주기. null = 장치가 받아들인 타임아웃 / 3.
+ ///
+ /// 하트비트(CCP 읽기) 주기. null = 장치가 받아들인 타임아웃 / 3. 장치가 0 이나 int 에 들어가지 않는 값(2^31 ms 이상)을
+ /// 되돌려 주면 / 3.
+ ///
public int? HeartbeatPeriodMs { get; set; }
///
/// PENDING_ACK 이 늘릴 수 있는 추가 대기의 상한. PENDING_ACK 을 받은 요청 하나가 GVCP 줄을 붙드는 시간이 여기서 정해진다.
@@ -31,6 +34,10 @@ public sealed class GevDeviceOpt
/// 자동 값은 하트비트를 시작하기 직전에 정해진다. 여는 동안에는 아직 하트비트가 없으므로 채널 기본값
/// ()으로 열고, 하트비트가 없는
/// 세션은 그 값을 그대로 쓴다.
+ /// 값을 주면 자동 계산은 꺼진다. 0 은 "상한 없음" 도 "PENDING_ACK 무시" 도 아니고 연장이 0 이라는 뜻이다 —
+ /// 장치가 PENDING_ACK 로 답한 명령은 응답 창() 하나 안에 끝나야 하고, 못 끝나면
+ /// 와 무관하게 다시 보내지 않고 으로 끝난다(PENDING_ACK 는 장치가
+ /// 명령을 받아 실행 중이라는 대답이라 재전송하면 두 번 실행될 수 있다). 응답 창 안에 온 본 응답은 그대로 받는다.
///
public int? MaxPendingAckWaitMs { get; set; }
/// GVCP 소켓을 묶을 호스트 주소. null = 자동(탐색 인터페이스 → 같은 서브넷 인터페이스 → OS 라우팅).
diff --git a/src/GevSharp/GevException.cs b/src/GevSharp/GevException.cs
index 1ea0a15..7c73f8d 100644
--- a/src/GevSharp/GevException.cs
+++ b/src/GevSharp/GevException.cs
@@ -7,7 +7,22 @@ public GevException(string message) : base(message) { }
public GevException(string message, Exception? inner) : base(message, inner) { }
}
-/// GVCP 요청이 재시도까지 전부 응답 없이 끝났다.
+///
+/// 기다리던 응답이 시한 안에 오지 않았다. 세 경우에 나고, 다시 시도해도 되는지가 경우마다 다르다(메시지가 어느 경우인지 밝힌다).
+///
+/// - GVCP 요청이 재전송까지 전부 응답 없이 끝났다. 장치가 명령을 받았는지는 알 수 없다.
+/// - 장치가 PENDING_ACK 로 "받아서 실행 중" 이라고 답한 뒤 허락된 연장(,
+/// ) 안에 끝내지 못했다. 명령이 이미 장치에 있으므로 라이브러리는 재전송하지 않는다 —
+/// 장치가 그 명령을 실행했을 수 있으니, 호출자가 같은 명령을 다시 보내면 두 번 실행될 수 있다. 장치는 살아 있으므로 카메라 XML 적재는
+/// 이 경우를 장치 상실로 보지 않고 다른 URL 로 넘어간다.
+/// - 카메라 XML 을 HTTP 로 받다가 를 넘겼다(GVCP 와 무관). GevXmlLoader.LoadFromUrlAsync 는
+/// 이 예외를 그대로 던진다.
+///
+/// First/Second URL 을 차례로 시도하는 GevXmlLoader.LoadAsync· 는 둘째·셋째 경우를 감싸지 않은 채
+/// 내지 않는다 — 두 URL 의 실패를 모은 으로 내고, 이 예외는 그 메시지에(마지막 실패였으면
+/// 으로도) 실린다. 그 둘이 감싸지 않고 던지는 이 예외는 첫째 경우, 곧 장치를 잃은 것뿐이다.
+/// 에서 파생한다 — 이 아니므로 catch (TimeoutException) 에는 걸리지 않는다.
+///
public sealed class GevTimeoutException : GevException
{
public GevTimeoutException(string message) : base(message) { }
@@ -65,7 +80,10 @@ public GenApiException(string message, string? nodeName = null, Exception? inner
}
}
-/// GVSP 스트림이 닫혔거나 시작되지 않은 상태에서 수신을 요청했다.
+///
+/// GVSP 스트림이 닫혔거나 시작되지 않은 상태에서 수신을 요청했다. 닫힘에는 정지·해제 말고도 수신 스레드가 스스로 끝난
+/// 경우(스트림 소켓 사망 등)가 있다 — 그때는 도 거짓이 되며, 스트림을 정지해 정리한다.
+///
public sealed class GevStreamClosedException : GevException
{
public GevStreamClosedException(string message) : base(message) { }
diff --git a/src/GevSharp/GevSharp.csproj b/src/GevSharp/GevSharp.csproj
index 05e3326..cc35c33 100644
--- a/src/GevSharp/GevSharp.csproj
+++ b/src/GevSharp/GevSharp.csproj
@@ -42,7 +42,8 @@
-
+
diff --git a/src/GevSharp/Gvcp/GevDiscovery.cs b/src/GevSharp/Gvcp/GevDiscovery.cs
index cf7a232..f52d810 100644
--- a/src/GevSharp/Gvcp/GevDiscovery.cs
+++ b/src/GevSharp/Gvcp/GevDiscovery.cs
@@ -10,7 +10,15 @@ public sealed class GevDiscoveryOpt
{
/// 응답을 모으는 시간.
public int TimeoutMs { get; set; } = 1000;
- /// 창 안에서 DISCOVERY_CMD 를 보내는 총 횟수(첫 전송 포함). 늦게 켜진 장치·유실된 첫 패킷을 잡는다.
+ ///
+ /// 창 안에서 DISCOVERY_CMD 를 보내는 총 횟수(첫 전송 포함, 대상마다). 늦게 켜진 장치·유실된 첫 패킷을 잡는다.
+ /// 1 이상이어야 한다 — 1 미만이면 가 을 던진다.
+ ///
+ /// 전송은 창이 열린 시각부터 일정한 간격(창 길이 ÷ Repeat, 최대 200 ms, 최소 1 ms)으로 예약하고, 예약 시각이 창 밖으로 나가는
+ /// 전송은 보내지 않는다 — 반복이 창을 늘리지 않는다. 그래서 Repeat 가 보다 크면 실제 전송은
+ /// Repeat 번이 아니라 TimeoutMs 번(간격 1 ms)으로 줄어든다. 그 밖에는 Repeat 번을 모두 보낸다.
+ ///
+ ///
public int Repeat { get; set; } = 2;
/// null = 동작 중인 모든 IPv4 인터페이스(루프백 제외).
public IReadOnlyList? Interfaces { get; set; }
@@ -38,32 +46,66 @@ public static class GevDiscovery
private const int RxMaxConsecutiveFailures = 8;
private static int s_reqIdCounter;
- /// 모든(또는 지정한) 인터페이스에 DISCOVERY_CMD 를 브로드캐스트하고 창 동안 응답을 모아 MAC 으로 중복을 제거한다.
+ ///
+ /// 모든(또는 지정한) 인터페이스에 DISCOVERY_CMD 를 브로드캐스트하고 창() 동안 응답을 모아
+ /// MAC 으로 중복을 제거한다.
+ ///
+ ///
+ /// 빈 목록은 "창 동안 아무도 답하지 않았다" 만 뜻하지 않는다.
+ ///
+ /// - 보낼 인터페이스가 없으면 창을 열지 않고 곧바로 빈 목록을 돌려준다 — 가 빈 목록이거나,
+ /// null 인데 루프백 말고 동작 중(Up)인 IPv4 인터페이스가 없거나(꺼진 어댑터, 케이블이 빠진 카메라 NIC 처럼 링크가 없는 어댑터는
+ /// Up 이 아니다), 인터페이스 목록을 읽지 못한 경우다.
+ /// - 인터페이스는 있었지만 어느 것에서도 DISCOVERY_CMD 가 나가지 못해도 빈 목록이다 — 소켓을 묶지 못했거나 보낼 대상이
+ /// 없으면 곧바로, 전송이 전부 실패했으면 창이 끝난 뒤에 돌아온다.
+ ///
+ /// 어느 경우든 까닭을 에 Warn 으로 남긴다. 빈 결과를 "장치 없음" 과 가려야 하는 호출자는 Warn 을 받는 싱크를 붙인다.
+ ///
+ /// 가 0 이하이거나 가 1 미만.
+ /// 에 IPv4 가 아닌 주소가 있다.
+ /// 가 취소됐다.
public static async Task> DiscoverAsync(GevDiscoveryOpt? opt = null, CancellationToken ct = default)
{
opt ??= new GevDiscoveryOpt();
if (opt.TimeoutMs <= 0) throw new ArgumentOutOfRangeException(nameof(opt), "TimeoutMs must be positive");
- var repeat = Math.Max(1, opt.Repeat);
+ if (opt.Repeat < 1) throw new ArgumentOutOfRangeException(nameof(opt), "Repeat must be at least 1");
+ var repeat = opt.Repeat;
- var ifaces = SelectInterfaces(opt.Interfaces);
+ var ifaces = SelectInterfaces(opt.Interfaces, out var noIfaceReason);
if (ifaces.Count == 0)
{
- GevLog.Warn(LogSrc, "no usable IPv4 interface for discovery");
+ // 예외로 바꾸지 않고 빈 목록을 돌려준다 — 호출자(샘플·앱)는 빈 목록을 "찾은 장치 없음" 으로 다룬다. 대신 그 빈 목록이
+ // 창을 기다린 결과가 아니라는 것과 까닭을 경고로 밝힌다.
+ GevLog.Warn(LogSrc, $"discovery not sent: {noIfaceReason}; returning an empty list without waiting for the {opt.TimeoutMs} ms window");
return Array.Empty();
}
var reqId = GvcpChannel.NextReqId(ref s_reqIdCounter);
var packet = GvcpCmd.Discovery(allowBroadcastAck: true).ToArray(reqId);
- var tasks = new Task>[ifaces.Count];
+ var tasks = new Task<(List Found, int Sent)>[ifaces.Count];
for (var i = 0; i < ifaces.Count; i++)
tasks[i] = DiscoverOnInterfaceAsync(ifaces[i], packet, opt, repeat, ct);
var perInterface = await Task.WhenAll(tasks).ConfigureAwait(false);
var all = new List();
- foreach (var list in perInterface) all.AddRange(list);
+ var sentIfaces = 0;
+ foreach (var (found, sent) in perInterface)
+ {
+ all.AddRange(found);
+ if (sent > 0) sentIfaces++;
+ }
var result = Dedupe(all);
- GevLog.Info(LogSrc, $"discovery finished: {result.Count} device(s) from {all.Count} reply(ies) on {ifaces.Count} interface(s)");
+ if (sentIfaces == 0)
+ {
+ // 인터페이스마다 까닭(대상 없음·바인드 실패·전송 실패)은 이미 경고했다. 요약까지 "탐색을 마쳤다" 로 남기면
+ // 빈 결과가 "아무도 답하지 않았다" 로 읽힌다.
+ GevLog.Warn(LogSrc, $"no DISCOVERY_CMD was sent on any of {ifaces.Count} interface(s) (see the warnings above); the empty result does not mean that no device answered");
+ }
+ else
+ {
+ GevLog.Info(LogSrc, $"discovery finished: {result.Count} device(s) from {all.Count} reply(ies) on {sentIfaces} of {ifaces.Count} interface(s)");
+ }
return result;
}
@@ -93,17 +135,21 @@ internal static List BuildTargets(GevNet.IfInfo iface, GevDiscoveryO
return targets;
}
- private static async Task> DiscoverOnInterfaceAsync(GevNet.IfInfo iface, byte[] packet, GevDiscoveryOpt opt, int repeat, CancellationToken ct)
+ /// 한 인터페이스에서 탐색한다. Sent 는 실제로 나간 DISCOVERY_CMD 수 — 0 이면 이 인터페이스에서는 탐색이 일어나지 않았다(까닭은 경고로 남겼다).
+ private static async Task<(List Found, int Sent)> DiscoverOnInterfaceAsync(GevNet.IfInfo iface, byte[] packet, GevDiscoveryOpt opt, int repeat, CancellationToken ct)
{
var found = new List();
+ var sent = 0;
var targets = BuildTargets(iface, opt);
if (targets.Count == 0)
{
GevLog.Warn(LogSrc, $"{iface}: no discovery target (both broadcast modes disabled or mask unknown)");
- return found;
+ return (found, sent);
}
- UdpClient client;
+ // 소켓은 만드는 순간 OS 핸들을 쥔다 — 바인드·옵션 설정이 실패해도 여기서 닫지 않으면 핸들이 GC 종료자가 돌 때까지 남아,
+ // 탐색을 부를 때마다 실패한 인터페이스 수만큼 쌓인다.
+ UdpClient? client = null;
try
{
client = new UdpClient(AddressFamily.InterNetwork);
@@ -114,35 +160,60 @@ private static async Task> DiscoverOnInterfaceAsync(GevNet.I
}
catch (SocketException ex)
{
+ client?.Dispose();
GevLog.Warn(LogSrc, $"{iface}: cannot bind a discovery socket ({ex.SocketErrorCode})", ex);
- return found;
+ return (found, sent);
+ }
+ catch
+ {
+ client?.Dispose();
+ throw;
}
using (client)
{
var receiveTask = ReceiveDiscoveryRepliesAsync(client, iface.Address, found);
var startMs = GevClock.NowMs();
+ var windowEndMs = startMs + opt.TimeoutMs;
+ // r 번째 전송은 창이 열린 시각 + r × 간격에 예약한다. 앞 대기가 타이머 눈금만큼 늦게 깨어도 뒤 예약이 밀려 쌓이지 않고,
+ // 예약 시각이 창 밖인 전송은 보내지 않으므로 반복이 창을 늘리지 않는다. 간격은 1 ms 아래로 내리지 않아
+ // Repeat 가 창 길이(ms)보다 크면 창 길이만큼만 보낸다. 그 밖에는 (Repeat-1) × 간격 < 창 이라 Repeat 번을 모두 보낸다.
+ // 건너뛸지는 실제로 깬 시각이 아니라 예약 시각으로 가른다 — 굶주린 스케줄러가 늦게 깨웠다고 창 안에 예약된 전송을
+ // 빠뜨리면 보내는 횟수가 부하에 따라 달라진다. 늦게 깬 몫은 곧바로 보내므로 창을 넘기는 것은 늦게 깬 한 번뿐이다.
var intervalMs = Math.Min(RepeatIntervalMs, Math.Max(1, opt.TimeoutMs / repeat));
+ var rounds = 0;
try
{
for (var r = 0; r < repeat; r++)
{
+ if (r > 0)
+ {
+ var dueMs = startMs + (long)r * intervalMs;
+ if (dueMs >= windowEndMs) break;
+ var waitMs = dueMs - GevClock.NowMs();
+ if (waitMs > 0)
+ await Task.Delay((int)waitMs, ct).ConfigureAwait(false);
+ else
+ ct.ThrowIfCancellationRequested();
+ }
foreach (var target in targets)
{
try
{
await client.SendAsync(packet, packet.Length, target).ConfigureAwait(false);
+ sent++;
}
catch (SocketException ex)
{
GevLog.Warn(LogSrc, $"{iface}: DISCOVERY_CMD to {target} failed ({ex.SocketErrorCode})");
}
}
- if (r < repeat - 1)
- await Task.Delay(intervalMs, ct).ConfigureAwait(false);
+ rounds++;
}
+ if (rounds < repeat && GevLog.IsEnabled(GevLogLevel.Debug))
+ GevLog.Debug(LogSrc, $"{iface}: {rounds} of {repeat} DISCOVERY_CMD round(s) fit in the {opt.TimeoutMs} ms window at a {intervalMs} ms interval; the rest were not sent");
// 시계 값이 어긋나도 창 길이를 넘겨 기다리지 않는다.
- var remainingMs = Math.Min(opt.TimeoutMs - (GevClock.NowMs() - startMs), opt.TimeoutMs);
+ var remainingMs = Math.Min(windowEndMs - GevClock.NowMs(), opt.TimeoutMs);
if (remainingMs > 0)
await Task.Delay((int)remainingMs, ct).ConfigureAwait(false);
}
@@ -153,7 +224,7 @@ private static async Task> DiscoverOnInterfaceAsync(GevNet.I
await receiveTask.ConfigureAwait(false);
}
}
- return found;
+ return (found, sent);
}
///
@@ -267,14 +338,34 @@ internal static IReadOnlyList Dedupe(IEnumerable r
// ------------------------------------------------------------------ probe
- /// 주소 하나에 유니캐스트 DISCOVERY_CMD 를 보낸다. 서브넷을 넘어서도, 루프백 시뮬레이터에도 통한다. 응답이 없으면 null.
+ ///
+ /// 주소 하나에 유니캐스트 DISCOVERY_CMD 를 한 번 보내고(재시도 없음) 동안 응답을 기다린다.
+ /// 서브넷을 넘어서도, 루프백 시뮬레이터에도 통한다.
+ ///
+ ///
+ /// 온전한 DISCOVERY_ACK 가 오면 그 장치 정보. 아래 셋이면 null 이다 — null 은 "장치가 없다" 가 아니라 "쓸 수 있는 응답이 없었다" 다.
+ ///
+ /// - 시간 안에 응답이 없다. 닫힌 포트, 기다리던 것이 아닌 명령의 ack(버린다), PENDING_ACK 로 답한 뒤 끝내 완료하지 않은 경우도
+ /// 여기 든다. Debug 로그.
+ /// - 장치가 오류 status 로 답했다 — 장치는 거기 있지만 탐색을 거절했다. Warn 로그에 status 를 남긴다.
+ /// - 응답이 탐색 블록(248 바이트)보다 짧다. Warn 로그.
+ ///
+ /// 브로드캐스트 탐색()도 같은 응답(오류 status·짧은 응답)을 목록에 넣지 않고 건너뛴다 — 프로브는
+ /// 그 탐색을 주소 하나에 보내는 것이라 같은 응답을 같게 다룬다. 둘째·셋째를 "없음" 과 가려야 하는 호출자는 Warn 을 받는
+ /// 를 붙인다.
+ ///
+ /// 가 null.
+ /// 가 0 이하.
+ /// IPv4 주소가 아니거나, 보내기·받기가 소켓 오류로 실패했거나, 장치로 나가는 로컬 주소를 정할 수 없다.
+ /// 프로브용 소켓을 만들거나 묶지 못했다(드묾 — 이 경우만 감싸지 않고 그대로 나온다).
+ /// 가 취소됐다.
public static Task ProbeAsync(IPAddress address, int timeoutMs = 1000, CancellationToken ct = default)
{
if (address is null) throw new ArgumentNullException(nameof(address));
return ProbeAsync(new IPEndPoint(address, GvcpConst.Port), timeoutMs, ct);
}
- /// 포트를 지정한 프로브 — 표준 포트가 아닌 시뮬레이터용.
+ /// 포트를 지정한 프로브 — 표준 포트가 아닌 시뮬레이터용. null 이 되는 경우와 예외는 공개 오버로드와 같다.
internal static async Task ProbeAsync(IPEndPoint endpoint, int timeoutMs, CancellationToken ct)
{
if (timeoutMs <= 0) throw new ArgumentOutOfRangeException(nameof(timeoutMs));
@@ -284,20 +375,22 @@ internal static IReadOnlyList Dedupe(IEnumerable r
{
ack = await channel.RequestAsync(GvcpCmd.Discovery(allowBroadcastAck: false), ct).ConfigureAwait(false);
}
- catch (GevTimeoutException)
+ catch (GevTimeoutException ex)
{
- GevLog.Debug(LogSrc, $"probe {endpoint}: no reply within {timeoutMs} ms");
+ // 무응답·PENDING_ACK 뒤 미완료 — 채널의 메시지가 어느 쪽인지 말해 준다.
+ GevLog.Debug(LogSrc, $"probe {endpoint}: {ex.Message}; returning null");
return null;
}
catch (GevStatusException ex)
{
- GevLog.Warn(LogSrc, $"probe {endpoint}: {ex.Message}");
+ // 장치는 거기 있고 답도 했다 — null 이 "없음" 으로 읽히지 않게 경고로 남긴다.
+ GevLog.Warn(LogSrc, $"probe {endpoint}: {ex.Message}; the device is there but refused discovery, returning null");
return null;
}
if (ack.PayloadLength < GvbsAddr.DiscoveryDataLen)
{
- GevLog.Warn(LogSrc, $"probe {endpoint}: truncated DISCOVERY_ACK ({ack.PayloadLength} of {GvbsAddr.DiscoveryDataLen} bytes); ignored");
+ GevLog.Warn(LogSrc, $"probe {endpoint}: truncated DISCOVERY_ACK ({ack.PayloadLength} of {GvbsAddr.DiscoveryDataLen} bytes); returning null");
return null;
}
var local = GevNet.ResolveLocalAddress(endpoint.Address);
@@ -315,9 +408,9 @@ public static Task ForceIpAsync(PhysicalAddress mac, IPAddress ip, IPAddress sub
var cmd = GvcpCmd.ForceIp(mac, ip, subnet, gateway, allowBroadcastAck: true);
var packet = cmd.ToArray(GvcpChannel.NextReqId(ref s_reqIdCounter));
- var ifaces = SelectInterfaces(opt.Interfaces);
+ var ifaces = SelectInterfaces(opt.Interfaces, out var noIfaceReason);
if (ifaces.Count == 0)
- throw new GevException("no usable IPv4 interface to send FORCEIP");
+ throw new GevException($"no usable IPv4 interface to send FORCEIP: {noIfaceReason}");
var sent = 0;
foreach (var iface in ifaces)
@@ -365,12 +458,23 @@ private static int SendForceIp(Socket socket, byte[] packet, IPEndPoint target,
// ------------------------------------------------------------------ interfaces
- /// null 이면 동작 중인 비루프백 IPv4 인터페이스 전부. 지정된 주소는 마스크를 찾아 붙이고, 모르는 주소는 마스크 없이 쓴다.
- private static List SelectInterfaces(IReadOnlyList? explicitAddresses)
+ ///
+ /// null 이면 동작 중인 비루프백 IPv4 인터페이스 전부. 지정된 주소는 마스크를 찾아 붙이고, 모르는 주소는 마스크 없이 쓴다.
+ /// 지정한 주소는 하나마다 항목 하나가 되므로 목록이 비는 것은 지정 목록이 비었거나 자동 선택에서 고를 것이 없을 때뿐이다 —
+ /// 에 그 까닭(로그용 영어 문장)을 담는다. 목록이 비지 않았으면 쓰지 않는다.
+ ///
+ private static List SelectInterfaces(IReadOnlyList? explicitAddresses, out string emptyReason)
{
if (explicitAddresses is null)
- return GevNet.GetIpv4Interfaces(includeLoopback: false);
+ {
+ var up = GevNet.GetIpv4Interfaces(includeLoopback: false, out var enumerated);
+ emptyReason = enumerated
+ ? "no network interface other than loopback is up with an IPv4 address (a disabled adapter, or one without link such as an unplugged camera NIC, is not up)"
+ : "the host's network interfaces could not be enumerated";
+ return up;
+ }
+ emptyReason = "GevDiscoveryOpt.Interfaces is an empty list";
var known = GevNet.GetIpv4Interfaces(includeLoopback: true);
var list = new List(explicitAddresses.Count);
foreach (var addr in explicitAddresses)
diff --git a/src/GevSharp/Gvcp/GevNet.cs b/src/GevSharp/Gvcp/GevNet.cs
index 403fd91..c430ef0 100644
--- a/src/GevSharp/Gvcp/GevNet.cs
+++ b/src/GevSharp/Gvcp/GevNet.cs
@@ -30,7 +30,13 @@ internal sealed class IfInfo
}
/// 동작 중(Up)인 인터페이스의 IPv4 유니캐스트 주소를 전부 모은다. 조회 실패는 빈 목록 + 경고.
- internal static List GetIpv4Interfaces(bool includeLoopback)
+ internal static List GetIpv4Interfaces(bool includeLoopback) => GetIpv4Interfaces(includeLoopback, out _);
+
+ ///
+ /// 와 같되, 빈 목록이 "조회 자체가 실패했다"( = false, 경고를 남겼다)
+ /// 인지 "조회는 됐지만 해당하는 인터페이스가 없다" 인지를 알려 준다 — 호출자가 빈 결과의 까닭을 밝힐 수 있게.
+ ///
+ internal static List GetIpv4Interfaces(bool includeLoopback, out bool enumerated)
{
var list = new List();
NetworkInterface[] nics;
@@ -41,8 +47,10 @@ internal static List GetIpv4Interfaces(bool includeLoopback)
catch (Exception ex)
{
GevLog.Warn(LogSrc, "failed to enumerate network interfaces", ex);
+ enumerated = false;
return list;
}
+ enumerated = true;
foreach (var nic in nics)
{
diff --git a/src/GevSharp/Gvcp/GvbsAddr.cs b/src/GevSharp/Gvcp/GvbsAddr.cs
index 1d390ad..5ee24a9 100644
--- a/src/GevSharp/Gvcp/GvbsAddr.cs
+++ b/src/GevSharp/Gvcp/GvbsAddr.cs
@@ -51,7 +51,7 @@ public static class GvbsAddr
public const uint HeartbeatTimeout = 0x0938; // ms
public const uint TimestampTickFreqHigh = 0x093C;
public const uint TimestampTickFreqLow = 0x0940;
- public const uint TimestampControl = 0x0944; // 값(LSB 기준): 2 = reset, 1 = latch
+ public const uint TimestampControl = 0x0944; // 값(LSB 기준): 1 = reset, 2 = latch
public const uint TimestampLatchedHigh = 0x0948;
public const uint TimestampLatchedLow = 0x094C;
public const uint DiscoveryAckDelay = 0x0950;
diff --git a/src/GevSharp/Gvcp/GvcpChannel.cs b/src/GevSharp/Gvcp/GvcpChannel.cs
index 9ed778c..e161207 100644
--- a/src/GevSharp/Gvcp/GvcpChannel.cs
+++ b/src/GevSharp/Gvcp/GvcpChannel.cs
@@ -17,6 +17,7 @@ public sealed class GvcpChannelOpt
///
/// PENDING_ACK 가 요청한 추가 대기의 상한(한 요청 누적). PENDING_ACK 를 받은 요청은 재전송하지 않으므로
/// 한 요청에서 연장을 받는 시도는 하나뿐이고, 이 값이 곧 그 요청이 연장으로 쓸 수 있는 전부다.
+ /// 0 = 연장 없음: PENDING_ACK 를 받은 요청은 응답 창() 하나 안에 끝나야 하고, 못 끝나면 재전송 없이 시한 초과다.
///
public int MaxPendingAckWaitMs { get; set; } = DefaultMaxPendingAckWaitMs;
}
@@ -52,6 +53,13 @@ public sealed class GvcpChannel : IDisposable, IGvcpResendPort
///
public const string FailedIndexKey = "FailedIndex";
+ ///
+ /// PENDING_ACK 를 받은 요청이 허락된 연장 안에 끝나지 못해 난 의 에
+ /// 이 키로 true 를 넣는다. 형은 응답이 아예 없던 시한 초과와 같지만, 이쪽은 장치가 살아서 "받아서 실행 중" 이라고 답한 것이다 —
+ /// 그 차이로 "장치를 잃었다(다시 연결)" 를 가르는 자리(카메라 XML 적재)가 이 표식을 본다.
+ ///
+ internal const string PendingAckExpiredKey = "PendingAckExpired";
+
private readonly Socket _socket;
private readonly Thread _rxThread;
private readonly SemaphoreSlim _reqLock = new(1, 1);
@@ -71,6 +79,8 @@ public sealed class GvcpChannel : IDisposable, IGvcpResendPort
private int _reqIdCounter;
private volatile PendingRequest? _pending;
private volatile bool _isDisposed;
+ /// 수신 루프가 회복 불가로 채널을 스스로 닫을 때의 원인. 밖에서 닫았으면 null.
+ private Exception? _closeCause;
private long _staleAckCount;
private long _foreignPacketCount;
private long _malformedPacketCount;
@@ -78,10 +88,15 @@ public sealed class GvcpChannel : IDisposable, IGvcpResendPort
public GvcpChannel(IPEndPoint device, IPAddress? localAddress = null, GvcpChannelOpt? opt = null)
{
- DeviceEndPoint = device ?? throw new ArgumentNullException(nameof(device));
- _logSrc = $"{LogSrc} {DeviceEndPoint.Address}";
+ if (device is null) throw new ArgumentNullException(nameof(device));
if (device.AddressFamily != AddressFamily.InterNetwork)
throw new GevException($"{device} is not an IPv4 endpoint; GVCP runs over IPv4 only");
+ // 호출자의 IPEndPoint 를 그대로 쥐지 않고 사본을 만든다 — IPEndPoint 는 바뀌는 객체라, 호출자가 같은 객체를 다음 장치에
+ // 다시 쓰면(Port·Address 변경) 송신 주소(아래 직렬화 사본)는 그대로인데 응답 대조(HandlePacket)·DeviceEndPoint·로그만
+ // 따라 바뀌어 진짜 장치의 응답이 전부 남의 패킷으로 버려진다. 아래는 전부 이 사본에서 끌어낸다.
+ device = new IPEndPoint(device.Address, device.Port);
+ _device = device;
+ _logSrc = $"{LogSrc} {_device.Address}";
// 호출자가 준 인스턴스를 그대로 쥐지 않고 값만 옮겨 온다 — 채널이 세션에 맞춰 상한을 다시 정할 때(SetMaxPendingAckWaitMs)
// 호출자의 객체를, 나아가 같은 객체로 만든 다른 채널까지 조용히 바꿔 놓지 않기 위해서다.
// ⚠ GvcpChannelOpt 에 항목을 더하면 여기에도 더한다.
@@ -118,8 +133,22 @@ public GvcpChannel(IPEndPoint device, IPAddress? localAddress = null, GvcpChanne
GevLog.Debug(_logSrc, $"channel opened from {LocalEndPoint}");
}
+ ///
+ /// 채널이 닫힐 때 한 번 불린다 — 누가 닫았든(쥔 세션의 닫기, 수신 소켓이 회복 불가로 스스로 닫음, 호출자가 직접 닫음).
+ /// 인자는 스스로 닫은 경우의 원인이고 그 밖에는 null. 닫는 스레드에서 동기로 불리므로 가볍게 처리한다.
+ /// 이 채널을 쥔 세션은 이것으로 "열려 있다고 답하면서 모든 요청이 ObjectDisposedException 으로 끝나는" 창을 닫는다.
+ ///
+ internal Action? OnClosed { get; set; }
+
public IPEndPoint LocalEndPoint { get; }
- public IPEndPoint DeviceEndPoint { get; }
+ ///
+ /// 이 채널이 겨냥하는 장치 끝점. 부를 때마다 새 사본을 돌려준다 — 생성자에 넘긴 객체도, 여기서 받은 객체도 뒤에 바꿔 봐야
+ /// 이 채널은 처음 연 장치에 묶여 있다(응답 대조는 채널 안의 사본으로 한다).
+ ///
+ public IPEndPoint DeviceEndPoint => new(_device.Address, _device.Port);
+
+ /// 응답 대조·로그에 쓰는 장치 끝점 — 밖으로 내주지 않는다.
+ private readonly IPEndPoint _device;
/// 이 채널이 실제로 쓰는 타이밍 값(생성자에 넘긴 객체의 사본). 진단용으로 읽는다.
public GvcpChannelOpt Opt => _opt;
public bool IsDisposed => _isDisposed;
@@ -155,9 +184,11 @@ public async Task RequestAsync(GvcpCmd cmd, CancellationToken ct = defa
var reqId = NextReqId(ref _reqIdCounter);
var length = cmd.Length;
cmd.WriteTo(_sendBuf, reqId);
- var attempts = 1 + _opt.Retries;
+ // 시도 횟수는 long 으로 센다 — Retries = int.MaxValue("끝없이 재시도")에서 1 + Retries 가 int 로는 음수로 감겨
+ // 루프가 한 번도 돌지 않고 아무것도 보내지 않은 채 시한 초과로 끝난다. 루프 변수도 같이 넓혀야 2^31 번째에서 감기지 않는다.
+ var attempts = 1L + _opt.Retries;
- for (var attempt = 1; attempt <= attempts; attempt++)
+ for (var attempt = 1L; attempt <= attempts; attempt++)
{
ct.ThrowIfCancellationRequested();
var pending = new PendingRequest(reqId, cmd.ExpectedAck);
@@ -182,15 +213,20 @@ public async Task RequestAsync(GvcpCmd cmd, CancellationToken ct = defa
// 같은 명령을 또 보내면 두 번 실행될 수 있다. 재시도마다 연장 예산이 다시 붙어 줄을 붙드는 시간이
// (1 + Retries) 배로 늘어나는 것도 여기서 끊는다 — 하트비트가 그 줄에 같이 서 있다.
if (Interlocked.Read(ref pending.PendingDeadlineMs) > 0)
- throw new GevTimeoutException(
- $"{cmd.Name} to {DeviceEndPoint} was answered with PENDING_ACK but never completed within its {pending.BudgetMs} ms budget; "
+ {
+ var expired = new GevTimeoutException(
+ $"{cmd.Name} to {_device} was answered with PENDING_ACK but never completed within its {pending.BudgetMs} ms budget; "
+ "the command is not resent because the device has already taken it");
+ // 장치는 답했다 — 무응답 시한 초과와 형은 같아도 장치 상실로 읽히지 않게 표식을 단다.
+ expired.Data[PendingAckExpiredKey] = true;
+ throw expired;
+ }
if (GevLog.IsEnabled(GevLogLevel.Debug))
GevLog.Debug(_logSrc, $"{cmd.Name} req_id {reqId}: no reply within {_opt.TimeoutMs} ms (attempt {attempt}/{attempts})");
}
- throw new GevTimeoutException($"{cmd.Name} to {DeviceEndPoint} timed out after {attempts} attempt(s) of {_opt.TimeoutMs} ms");
+ throw new GevTimeoutException($"{cmd.Name} to {_device} timed out after {attempts} attempt(s) of {_opt.TimeoutMs} ms");
}
finally
{
@@ -210,7 +246,7 @@ private void Send(byte[] buffer, int length)
}
catch (SocketException ex)
{
- throw new GevException($"GVCP send to {DeviceEndPoint} failed: {ex.SocketErrorCode}", ex);
+ throw new GevException($"GVCP send to {_device} failed: {ex.SocketErrorCode}", ex);
}
}
@@ -321,7 +357,7 @@ public void SendNoAck(ReadOnlySpan packet)
}
catch (SocketException ex)
{
- throw new GevException($"GVCP send to {DeviceEndPoint} failed: {ex.SocketErrorCode}", ex);
+ throw new GevException($"GVCP send to {_device} failed: {ex.SocketErrorCode}", ex);
}
}
@@ -402,7 +438,9 @@ private void ReceiveLoop()
{
// 스스로 회복하지 않는 소켓 — 경고를 무한히 찍는 대신 채널을 닫아 요청 쪽이 즉시 실패하게 한다.
GevLog.Error(_logSrc, $"receive failed {consecutiveFailures} times in a row ({ex.SocketErrorCode}); closing the channel", ex);
- _pending?.Tcs.TrySetException(new GevException($"GVCP receive on {LocalEndPoint} kept failing ({ex.SocketErrorCode}); channel closed", ex));
+ var failure = new GevException($"GVCP receive on {LocalEndPoint} kept failing ({ex.SocketErrorCode}); channel closed", ex);
+ _pending?.Tcs.TrySetException(failure);
+ _closeCause = failure;
Dispose();
break;
}
@@ -438,11 +476,11 @@ private void ReceiveLoop()
private void HandlePacket(byte[] buf, int n, EndPoint from)
{
// 장치는 명령을 받은 그 소켓(주소+포트)에서 응답한다 — 다른 곳에서 온 것은 이 채널의 응답이 아니다.
- if (from is not IPEndPoint fromIp || !fromIp.Equals(DeviceEndPoint))
+ if (from is not IPEndPoint fromIp || !fromIp.Equals(_device))
{
Interlocked.Increment(ref _foreignPacketCount);
if (GevLog.IsEnabled(GevLogLevel.Trace))
- GevLog.Trace(LogSrc, $"ignored {n} bytes from {from} (device is {DeviceEndPoint})");
+ GevLog.Trace(LogSrc, $"ignored {n} bytes from {from} (device is {_device})");
return;
}
@@ -494,7 +532,11 @@ private void ThrowIfDisposed()
if (_isDisposed) throw new ObjectDisposedException(nameof(GvcpChannel));
}
- /// 소켓을 닫고 수신 스레드가 끝나기를 기다린다. 대기 중인 요청은 으로 끝난다.
+ ///
+ /// 소켓을 닫고 수신 스레드가 끝나기를 기다린다. 대기 중인 요청은 으로 끝나고,
+ /// 그 뒤의 요청도 전부 이다. 이 채널을 쥔 가 아직 열려 있었다면
+ /// 그 세션은 이 자리에서 제어권 상실로 넘어간다.
+ ///
public void Dispose()
{
if (_isDisposed) return;
@@ -509,6 +551,7 @@ public void Dispose()
}
_pending?.Tcs.TrySetException(new ObjectDisposedException(nameof(GvcpChannel)));
+ NotifyClosed();
if (Thread.CurrentThread != _rxThread && _rxThread.IsAlive && !_rxThread.Join(RxThreadJoinMs))
GevLog.Warn(_logSrc, "receive thread did not stop within the join timeout");
@@ -516,6 +559,21 @@ public void Dispose()
GevLog.Debug(_logSrc, $"channel closed (was {LocalEndPoint})");
}
+ /// 를 부른다. 받는 쪽의 실패가 닫기를 멈추지 않게 삼키고 남긴다.
+ private void NotifyClosed()
+ {
+ var callback = OnClosed;
+ if (callback is null) return;
+ try
+ {
+ callback(_closeCause);
+ }
+ catch (Exception ex)
+ {
+ GevLog.Error(_logSrc, "channel-closed callback threw", ex);
+ }
+ }
+
///
/// 주소를 한 번만 직렬화해 두고 에서 그 사본을 그대로 돌려주는 종단점.
/// 소켓은 송신할 때 이 버퍼를 읽기만 하므로 사본 하나를 계속 재사용할 수 있다.
diff --git a/src/GevSharp/Gvsp/GevStream.Receiver.cs b/src/GevSharp/Gvsp/GevStream.Receiver.cs
index ceb5133..90c3224 100644
--- a/src/GevSharp/Gvsp/GevStream.Receiver.cs
+++ b/src/GevSharp/Gvsp/GevStream.Receiver.cs
@@ -19,7 +19,7 @@ namespace GevSharp;
public sealed partial class GevStream
{
private const int MaxInFlightFrames = 4;
- private const int IdleReceiveTimeoutMs = 200;
+ private const int IdleWaitMs = 200;
private const int ScratchSlackBytes = 64;
private const int RecentClosedCount = 8;
/// 한 프레임의 패킷 id 상한 — 비트·마감 배열 크기를 묶는다(576 바이트 패킷으로 140 MB 프레임까지).
@@ -42,7 +42,7 @@ public sealed partial class GevStream
private long _punchIntervalTicks;
private long _lastInboundTicks;
private long _lastPunchTicks;
- private int _activeReceiveTimeoutMs;
+ private int _activeWaitMs;
private bool _isResendEnabled;
private double _requestRatio;
private bool _isDeliverIncomplete;
@@ -50,6 +50,7 @@ public sealed partial class GevStream
private bool _hasLoggedChunkOverflow;
private bool _hasLoggedShortLeader;
private bool _hasLoggedShortBlock;
+ private bool _hasLoggedStrideChange;
private readonly FrameSlot?[] _active = new FrameSlot?[MaxInFlightFrames];
private readonly FrameSlot[] _freeSlots = new FrameSlot[MaxInFlightFrames];
@@ -60,7 +61,6 @@ public sealed partial class GevStream
private readonly ulong[] _recentClosedTimestamp = new ulong[RecentClosedCount];
private int _recentClosedNext;
private int _recentClosedFilled;
- private int _currentReceiveTimeoutMs = -1;
private int _consecutiveReceiveErrors;
private SocketError _receiveExitError;
private uint _loggedUnsupportedPayloadTypes;
@@ -252,11 +252,12 @@ private void InitReceiver(int packetSize)
_packetTimeoutTicks = MsToTicks(_opt.PacketTimeoutMs);
_retentionTicks = MsToTicks(_opt.FrameRetentionMs);
// 조립 중인 프레임이 있으면 가장 짧은 마감(유예) 간격으로 깨어나 구멍을 본다. 패킷이 흐르는 동안은 타임아웃이 걸리지 않는다.
- _activeReceiveTimeoutMs = Math.Max(1, Math.Min(_opt.InitialPacketTimeoutMs, _opt.PacketTimeoutMs));
+ _activeWaitMs = Math.Max(1, Math.Min(_opt.InitialPacketTimeoutMs, _opt.PacketTimeoutMs));
_maxPayloadBytes = Math.Min(_opt.MaxPayloadBytes, int.MaxValue - ScratchSlackBytes);
_hasLoggedPayloadCeiling = false;
_hasLoggedShortLeader = false;
_hasLoggedShortBlock = false;
+ _hasLoggedStrideChange = false;
_punchIntervalTicks = _opt.FirewallTraversal && _opt.FirewallTraversalIntervalMs > 0
? MsToTicks(_opt.FirewallTraversalIntervalMs)
: 0;
@@ -275,7 +276,6 @@ private void InitReceiver(int packetSize)
_activeCount = 0;
_recentClosedNext = 0;
_recentClosedFilled = 0;
- _currentReceiveTimeoutMs = -1;
}
private static long MsToTicks(int ms) => (long)ms * Stopwatch.Frequency / 1000;
@@ -288,25 +288,55 @@ private void ReceiveLoop()
try
{
+ // 기다림은 소켓 수신 시한(SO_RCVTIMEO)이 아니라 Poll 로 한다. 시한을 건 블로킹 수신은 윈도우에서 만료되는 순간 막 도착한
+ // 데이터그램을 잃을 수 있다 — 만료 뒤 소켓 상태는 정해지지 않는다고 플랫폼이 밝히고 있고, 실기에서 패킷 간격이 대기
+ // 간격보다 넓을 때 옛 대기가 60 초에 9/46·8/39 장을 불완전으로 만들었다(지금 대기 0/39·0/39, docs/evaluation.md
+ // 「Receive wait on Windows」). 그래서 받을 것이 있을 때만 논블로킹으로 받고(흐르는 동안은 호출 하나로 끝난다), 비었을 때만
+ // Poll 로 기다린다. Poll 은 데이터를 건드리지 않고 기다리기만 하므로 경계에서 잃을 것이 없다.
+ // 비용: .NET Framework(netstandard2.0 자산)의 Poll 은 부를 때마다 작은 배열(약 40 B)을 할당한다 — 소켓이 빌 때마다 부르므로
+ // 최대 속도에서는 대략 패킷당 한 번이다. net6 이상은 0 B. 잃지 않는 쪽을 택했다.
+ try { socket.Blocking = false; }
+ catch (ObjectDisposedException) { return; }
+
while (!_isStopRequested)
{
int length;
+ SocketError error;
try
{
- // 타임아웃 조정도 try 안에서 — 정지 중 닫힌 소켓은 여기서도 ObjectDisposedException 을 내며, 그것은 오류가 아니라 정상 종료다.
- UpdateReceiveTimeout(socket);
- length = socket.Receive(_scratch, 0, _scratch.Length, SocketFlags.None);
+ length = socket.Receive(_scratch, 0, _scratch.Length, SocketFlags.None, out error);
+ if (error == SocketError.WouldBlock)
+ {
+ // 조립 중 대기 간격은 옵션에서 오므로 상한이 없다 — 한가할 때의 간격으로 묶어 마이크로초 환산이 넘치지 않게 하고,
+ // 매우 긴 시한을 준 경우에도 방화벽 유지·마감 점검이 그 간격으로는 돈다.
+ var waitMicros = Math.Min(_activeCount > 0 ? _activeWaitMs : IdleWaitMs, IdleWaitMs) * 1000;
+ if (!socket.Poll(waitMicros, SelectMode.SelectRead))
+ {
+ OnWaitElapsed();
+ }
+ continue;
+ }
}
catch (SocketException ex)
{
- if (_isStopRequested || !HandleReceiveError(ex)) break;
+ // Poll 의 오류(닫히는 중인 소켓 등)는 예외로 온다 — 수신 오류와 같은 분류로 다룬다.
+ if (_isStopRequested || !HandleReceiveError(ex.SocketErrorCode, ex)) break;
continue;
}
catch (ObjectDisposedException)
{
+ // 정지가 아닌데 소켓이 닫혔다 — 사유를 "성공" 으로 남기지 않는다(플랫폼에 따라 Poll 이 예외 대신 참을 돌려주고
+ // 다음 수신에서 여기로 온다).
+ if (!_isStopRequested) _receiveExitError = SocketError.NotSocket;
break;
}
+ if (error != SocketError.Success)
+ {
+ if (_isStopRequested || !HandleReceiveError(error, null)) break;
+ continue;
+ }
+
_consecutiveReceiveErrors = 0;
OnPacket(length, Stopwatch.GetTimestamp());
}
@@ -314,6 +344,7 @@ private void ReceiveLoop()
catch (Exception ex)
{
GevLog.Error(_logSrc, "Receiver thread terminated by an unexpected error.", ex);
+ MarkReceiverEnded();
_queue?.Complete(new GevStreamClosedException("Receiver thread failed: " + ex.Message));
}
finally
@@ -322,30 +353,40 @@ private void ReceiveLoop()
if (!_isStopRequested)
{
// 정지 요청 없이 나왔다면 소켓이 죽은 것이다 — 소비자가 영원히 기다리지 않게 큐를 닫는다(이미 닫혔으면 무시된다).
+ MarkReceiverEnded();
_queue?.Complete(new GevStreamClosedException($"Receiver thread stopped: stream socket receive failed ({_receiveExitError})."));
}
GevLog.Debug(_logSrc, $"Receiver thread on port {LocalPort} exited.");
}
}
- /// 수신 오류 분류. 계속 돌아도 되면 true, 루프를 끝내야 하면 false.
- private bool HandleReceiveError(SocketException ex)
+ ///
+ /// 수신 스레드가 정지 요청 없이 끝날 때 상태를 "시작됨" 에서 "스스로 끝남" 으로 내린다. 큐를 닫기 **전에** 불러야
+ /// 닫힘을 받은 소비자가 도 거짓으로 본다. 정지가 이미 상태를 가져갔으면 아무것도 바꾸지 않는다.
+ ///
+ private void MarkReceiverEnded() => Interlocked.CompareExchange(ref _state, StateFaulted, StateStarted);
+
+ /// 기다림이 아무것도 받지 못하고 끝났다 — 방화벽 매핑을 살피고 조립 중인 프레임의 구멍·마감을 본다.
+ private void OnWaitElapsed()
+ {
+ var now = Stopwatch.GetTimestamp();
+ MaybePunchFirewall(now);
+ OnTick(now);
+ }
+
+ /// 수신 오류 분류. 계속 돌아도 되면 true, 루프를 끝내야 하면 false. ex 는 예외로 온 경우에만 있다(로그용).
+ private bool HandleReceiveError(SocketError code, SocketException? ex)
{
- switch (ex.SocketErrorCode)
+ switch (code)
{
case SocketError.TimedOut:
case SocketError.WouldBlock:
- // IOPending 은 "겹친 수신이 아직 끝나지 않았다" 는 뜻이지 오류가 아니다 — 이번 호출에 데이터가 실려 오지 않았을 뿐이고
- // 잃은 것도 없다. 윈도우에서 수신 타임아웃을 오가며 바꾸는 블로킹 소켓이 이따금 이 값을 돌려준다(실카메라 풀레이트
- // 60 초에 7 회 관측, 그 구간에도 누락 패킷 0). 오류로 다루면 경고가 쌓이고 1 ms 를 자는 사이 침묵이 길어져
- // 보내지도 않은 꼬리를 재요청하게 된다 — 타임아웃과 똑같이 "한 번 더 받아 보자" 로 넘긴다.
+ // IOPending 은 "겹친 수신이 아직 끝나지 않았다" 는 뜻이다 — 수신 시한을 건 블로킹 소켓이 윈도우에서 이따금 돌려주던 값이고,
+ // 지금의 논블로킹 수신 + Poll 대기에서는 나오지 않아야 한다. 그래도 나오면 오류로 쌓지 않고 기다림이 끝난 것으로 다룬다
+ // (1 ms 를 자면 침묵이 길어져 보내지도 않은 꼬리를 재요청하게 된다).
case SocketError.IOPending:
- {
- var now = Stopwatch.GetTimestamp();
- MaybePunchFirewall(now);
- OnTick(now);
+ OnWaitElapsed();
return true;
- }
case SocketError.MessageSize:
_stats.IncPacketsIgnored();
if (!_hasLoggedOversize)
@@ -361,19 +402,19 @@ private bool HandleReceiveError(SocketException ex)
case SocketError.OperationAborted:
case SocketError.NotSocket:
case SocketError.Shutdown:
- _receiveExitError = ex.SocketErrorCode;
+ _receiveExitError = code;
return false;
default:
// 분류되지 않은 오류 — 처음 한 번만 남기고 잠깐 쉬었다 다시 시도하되, 계속되면 수신을 포기한다(무한 재시도·로그 홍수 방지).
_consecutiveReceiveErrors++;
if (_consecutiveReceiveErrors == 1)
{
- GevLog.Warn(_logSrc, $"Stream socket receive failed: {ex.SocketErrorCode}; retrying.", ex);
+ GevLog.Warn(_logSrc, $"Stream socket receive failed: {code}; retrying.", ex);
}
else if (_consecutiveReceiveErrors >= MaxConsecutiveReceiveErrors)
{
- GevLog.Error(_logSrc, $"Stream socket receive kept failing with {ex.SocketErrorCode} for {_consecutiveReceiveErrors} consecutive attempts; receiver stopped.", ex);
- _receiveExitError = ex.SocketErrorCode;
+ GevLog.Error(_logSrc, $"Stream socket receive kept failing with {code} for {_consecutiveReceiveErrors} consecutive attempts; receiver stopped.", ex);
+ _receiveExitError = code;
return false;
}
Thread.Sleep(1);
@@ -381,18 +422,10 @@ private bool HandleReceiveError(SocketException ex)
}
}
- private void UpdateReceiveTimeout(Socket socket)
- {
- var desired = _activeCount > 0 ? _activeReceiveTimeoutMs : IdleReceiveTimeoutMs;
- if (desired == _currentReceiveTimeoutMs) return;
- socket.ReceiveTimeout = desired;
- _currentReceiveTimeoutMs = desired;
- }
-
///
/// 인바운드가 오래 끊겼으면 방화벽 매핑을 살리는 한 바이트를 다시 보낸다. 상태 기반 방화벽의 매핑은 유휴로 두면 만료되고,
/// 그러면 다시 흐르기 시작한 GVSP 가 통째로 버려진다 — 트리거 간격이 벌어지거나 획득을 멈춘 채 스트림을 열어 둔 경우다.
- /// 패킷이 흐르는 동안에는 여기까지 오지 않는다(수신이 타임아웃될 때만 불린다).
+ /// 패킷이 흐르는 동안에는 여기까지 오지 않는다(수신 대기가 아무것도 받지 못하고 끝날 때만 불린다).
///
private void MaybePunchFirewall(long now)
{
@@ -464,6 +497,7 @@ private void OnPacket(int length, long now)
switch (view.ContentType)
{
case GvspConst.ContentLeader:
+ if (slot is not null && IsRestartOverLoneLeader(slot, in view, now)) ReopenOverLoneLeader(slot, in view, now);
if (slot is null)
{
if (!ShouldOpenForLeader(in view)) return;
@@ -568,6 +602,48 @@ private bool ShouldOpenForLeader(in GvspPacketView view)
return true;
}
+ ///
+ /// 리더만 온 채 멈춘 프레임에 같은 블록 번호의 새 리더가 왔는지 — 장치가 그 블록을 버리고(정지) 촬영을 다시 시작해 번호를 다시 센 것이다.
+ /// 리더만 온 가장 새 프레임은 보존 시간으로도 닫히지 않으므로(), 이것을 가리지 않으면 새 리더가 중복으로
+ /// 버려지고 새 페이로드가 옛 리더의 슬롯에 실려 옛 타임스탬프·기하로 완성 처리된다.
+ /// 증거가 있을 때만 그렇게 본다: 옛 프레임에 페이로드도 트레일러도 없고, 재요청 간격 이상 조용했으며, 새 리더가 리센드 사본이 아니고
+ /// 타임스탬프가 옛 리더와 같지 않아야 한다(같으면 늦게 도착한 사본이다). 재요청 간격 안에 다시 시작한 리더는 여전히 중복으로 버려진다.
+ ///
+ private bool IsRestartOverLoneLeader(FrameSlot slot, in GvspPacketView view, long now)
+ {
+ if (!slot.HasLeader || slot.HasTrailer || slot.HighestPacketId != 0 || slot.IsSkipped) return false;
+ if (view.IsResent || now - slot.LastPacketTicks < _packetTimeoutTicks) return false;
+ var timestamp = GvspImageLeader.TryRead(view.Data, out var leader) ? leader.Timestamp : 0;
+ return timestamp == 0 || timestamp != slot.Meta.Timestamp;
+ }
+
+ ///
+ /// 리더만 온 채 멈춘 프레임을 버리고 같은 슬롯을 새 리더를 받을 수 있게 비운다. 버린 프레임은 불완전 한 장으로 세고 로 알린다.
+ /// 받은 이미지 바이트가 하나도 없으므로 여도 내보내지 않는다 — 이 슬롯 앞에서
+ /// 아직 조립 중인 더 오래된 프레임을 앞질러 내보내지 않기 위해서이기도 하다.
+ /// 닫고 새로 열지 않고 제자리에서 다시 쓰는 것은 닫힌 블록 기록에 옛 타임스탬프를 남기지 않기 위해서다 — 남기면 같은 번호의 새 프레임이
+ /// 닫힌 뒤 그 늦은 사본이 옛 기록과 비교돼 새 프레임으로 열린다.
+ ///
+ private void ReopenOverLoneLeader(FrameSlot slot, in GvspPacketView view, long now)
+ {
+ var expected = slot.ExpectedPackets;
+ if (GevLog.IsEnabled(GevLogLevel.Debug))
+ {
+ GevLog.Debug(_logSrc, $"Block {slot.BlockId}: a new leader arrived after {(now - slot.LastPacketTicks) * 1000 / Stopwatch.Frequency} ms of silence "
+ + "over a frame that had received only its leader; the device restarted the block, so the old frame is dropped as incomplete.");
+ }
+ _stats.IncFramesIncomplete();
+ _stats.AddPacketsMissing(expected);
+ RaiseDropped(slot.BlockId, GevFrameDropReason.Incomplete, expected, expected, 0);
+ if (slot.Buf is not null)
+ {
+ _pool.Return(slot.Buf, slot.BufVersion);
+ slot.Buf = null;
+ }
+ slot.Reset(view.BlockId, view.IsExtendedId, now);
+ slot.EnsureCapacity(1);
+ }
+
private static bool IsOlderBlock(ulong id, ulong newest, bool extendedIds)
{
if (id == newest) return false;
@@ -862,6 +938,7 @@ private void OnPayload(FrameSlot slot, in GvspPacketView view, long now)
/// 패킷당 데이터 길이를 배운다. 기본은 SCPS 에서 계산한 값이고, 첫 페이로드(id 1)가 프레임보다 짧으면 그 길이가 진짜 값이다.
/// id 1 을 못 받았으면 마지막이 아닌 것이 확실한 패킷(id < 예상 수)의 길이로 배운다 — 아직 아무 바이트도 싣기 전이라 오프셋이 어긋나지 않는다.
/// 기본값보다 긴 패킷이 오면 장치가 SCPS 를 무시하는 것이므로 그 길이를 따른다.
+ /// 어느 쪽이든 id 2 이상을 이미 옛 간격으로 실은 뒤에 배우면 늦었다 — 가 그 프레임을 버린다.
///
private void LearnDataBytes(FrameSlot slot, uint id, int length)
{
@@ -905,8 +982,22 @@ private void GrowPayloadHint(long bytes)
_payloadSizeHint = (int)bytes;
}
+ ///
+ /// 패킷당 데이터 길이(= 패킷 간격)를 바꾼다. id 2 이상을 이미 옛 간격으로 실었다면 그 바이트는 틀린 자리에 있고 옮길 길이 없다
+ /// (id 1 만은 간격과 무관하게 0 에 실린다). 그런 프레임은 오류로 버린다 — 그대로 두면 받은 패킷 수는 다 차고, 받은 끝은
+ /// 가장 먼 끝이라 간격이 줄 때는 오히려 리더 크기를 넘고, 청크 프레임은 애초에 패킷 수로만 완성을 가리므로 어긋난 바이트와
+ /// 그 사이에 남은 이전 프레임 바이트가 완성 프레임으로 나간다. 리더와 id 1 이 함께 유실돼 협상값에서 구한 간격으로 먼저 실은
+ /// 뒤에야 진짜 간격을 배우는 경우다.
+ ///
private void SetDataBytes(FrameSlot slot, int dataBytes)
{
+ if (slot.HighestPacketId >= 2 && dataBytes != slot.DataBytes)
+ {
+ LogStrideChangeOnce(slot, dataBytes);
+ // 장치가 아니라 수신기 쪽 사정이다 — 협상값보다 짧거나(허용) 긴(무시) 패킷을 보내는 장치는 흔하고, 자리를 짐작한 것은 수신기다.
+ MarkSkipped(slot, GevFrameDropReason.Error, GvcpConst.StatusLocalProblem);
+ return;
+ }
if (GevLog.IsEnabled(GevLogLevel.Debug))
{
GevLog.Debug(_logSrc, $"Block {slot.BlockId}: payload bytes per packet {slot.DataBytes} -> {dataBytes}.");
@@ -1261,7 +1352,8 @@ private bool SendResend(FrameSlot slot, uint first, uint last, long now, int max
/// 오래된 순서로 슬롯을 본다. 완성됐거나 포기해야 할 프레임은 닫고, 아직 기다려야 하는 프레임을 만나면 그 뒤의 프레임은 닫지 않는다(순서 보존).
/// 버퍼를 쥐지 않은(건너뛰기) 슬롯은 순서를 막지 않는다.
/// 포기 시점: 리센드를 더 요청하지 않는 프레임(예산 소진·장치 거절·리센드 꺼짐)은 마지막 패킷 뒤 재요청 간격 하나만 더 기다리고,
- /// 그 밖의 프레임은 보존 시간까지 기다린다. 기다리는 프레임은 마감이 되거나 꼬리가 확정될 때 구멍을 다시 본다.
+ /// 그 밖의 프레임은 보존 시간까지 기다린다. 버리기로 한 프레임은 트레일러를 받으면 곧바로, 못 받으면 리센드가 켜져 있을 때 보존 시간,
+ /// 꺼져 있을 때 재요청 간격 뒤에 닫는다. 기다리는 프레임은 마감이 되거나 꼬리가 확정될 때 구멍을 다시 본다.
///
private void CheckCompletion(long now, FrameSlot? current)
{
@@ -1275,7 +1367,11 @@ private void CheckCompletion(long now, FrameSlot? current)
if (slot.IsSkipped)
{
- if (slot.HasTrailer || idleTicks >= _retentionTicks)
+ // 버리기로 한 프레임은 트레일러를 받거나 조용해지면 닫는다. 리센드가 꺼져 있으면 보존 시간이 아니라 재요청 간격을 쓴다 —
+ // 옵션 설명과 시작 로그가 "보존 시간은 쓰이지 않는다" 고 알리는데 여기만 보존 시간을 쓰면, FrameDropped 와 버림 계수기가
+ // 그만큼 늦고 조립 슬롯 하나가 그동안 묶인다.
+ var skipGiveUpTicks = _isResendEnabled ? _retentionTicks : _packetTimeoutTicks;
+ if (slot.HasTrailer || idleTicks >= skipGiveUpTicks)
{
CloseSlot(i);
continue;
@@ -1299,7 +1395,8 @@ private void CheckCompletion(long now, FrameSlot? current)
CloseSlot(i);
continue;
}
- // 리더만 온 가장 새 프레임은 기다린다 — 노출이 긴 촬영에서 리더가 먼저 오는 장치가 있다.
+ // 리더만 온 가장 새 프레임은 기다린다 — 노출이 긴 촬영에서 리더가 먼저 오는 장치가 있다. 그 사이 장치가 같은 블록 번호로
+ // 다시 시작하면 OnPacket 이 새 리더로 이 슬롯을 다시 연다(IsRestartOverLoneLeader).
var isLoneLeader = isNewest && slot.HasLeader && slot.ReceivedPayloads == 0 && !slot.HasTrailer;
if (!isLoneLeader)
{
@@ -1344,9 +1441,20 @@ private static bool IsComplete(FrameSlot slot)
=> slot.HasLeader && slot.Buf is not null && slot.ExpectedPackets > 0 && slot.ReceivedPayloads >= slot.ExpectedPackets
&& (slot.ExpectedBytes < 0 || slot.ReceivedEnd >= slot.ExpectedBytes);
- /// 트레일러가 약속한 패킷은 다 받았는데 리더가 알린 바이트에 못 미친다 — 장치가 블록을 끊었고 더 올 것이 없다.
+ ///
+ /// 트레일러가 약속한 패킷은 다 받았는데 리더가 알린 바이트에 못 미친다 — 장치가 블록을 끊었고 더 올 것이 없다.
+ /// 트레일러가 id 1 로 왔으면(첫 페이로드 전에 끊겼다) 약속한 패킷 수는 0 이고 그것도 끊긴 블록이다 — 트레일러가 정한 0 은
+ /// "아직 모름" 이 아니므로 보존 시간까지 기다릴 까닭이 없다.
+ ///
+ /// 트레일러를 잃은 프레임은 여기에 걸리지 않고 보존 시간(리센드가 꺼져 있으면 재요청 간격)까지 기다린다 — 리더가 알린 패킷을 다 받았는데 바이트만 모자라더라도
+ /// (크기 규칙이 장치보다 크게 셌을 때. 실기에서는 본 적 없다) 그렇다. 늦게 온 트레일러가 가변 높이의 실제 줄 수를 알리면
+ /// 가 크기를 줄여 완성시킬 수 있으므로, 침묵만으로 일찍 닫으면 살릴 수 있던 프레임을 버린다.
+ /// 트레일러는 리센드로 묻지 않으므로( 는 예상 패킷 수까지만 훑는다) 끝내 안 오면 결과는 어차피 불완전이고,
+ /// 달라지는 것은 닫히는 시각(최대 보존 시간)뿐이다 — 어느 쪽이든 모자란 프레임을 완성으로 내보내지는 않는다.
+ ///
+ ///
private static bool IsCutShort(FrameSlot slot)
- => slot.HasLeader && slot.HasTrailer && slot.Buf is not null && slot.ExpectedPackets > 0 && slot.ReceivedPayloads >= slot.ExpectedPackets
+ => slot.HasLeader && slot.HasTrailer && slot.Buf is not null && slot.ReceivedPayloads >= slot.ExpectedPackets
&& slot.ExpectedBytes >= 0 && slot.ReceivedEnd < slot.ExpectedBytes;
///
@@ -1368,6 +1476,23 @@ private void LogCutShort(FrameSlot slot)
}
}
+ /// 간격을 잘못 짐작해 버린 프레임은 스트림당 한 번만 경고하고, 그 뒤로는 오류 통계· 로 센다.
+ private void LogStrideChangeOnce(FrameSlot slot, int dataBytes)
+ {
+ if (!_hasLoggedStrideChange)
+ {
+ _hasLoggedStrideChange = true;
+ GevLog.Warn(_logSrc, $"Block {slot.BlockId}: payload packets were already placed at {slot.DataBytes} bytes per packet before a packet showed "
+ + $"the device sends {dataBytes}; bytes already placed cannot be moved, so the frame is dropped as an error. This happens when the leader "
+ + "and the first payload packet are both lost and the device's packet length differs from the negotiated packet size. "
+ + "Further occurrences are counted but not logged.");
+ }
+ else if (GevLog.IsEnabled(GevLogLevel.Debug))
+ {
+ GevLog.Debug(_logSrc, $"Block {slot.BlockId}: packet stride {slot.DataBytes} -> {dataBytes} after bytes were placed; frame dropped.");
+ }
+ }
+
private void CloseSlot(int index)
{
var slot = _active[index]!;
@@ -1422,7 +1547,9 @@ private void FinishSlot(FrameSlot slot)
}
RaiseDropped(slot.BlockId, GevFrameDropReason.Incomplete, missing, expected, 0);
- if (_isDeliverIncomplete && slot.HasLeader && buf is not null && slot.ExpectedPackets > 0)
+ // 트레일러가 페이로드 0 개로 끊은 블록도 크기는 리더가 알려 주었으므로 다른 끊긴 블록처럼 0 으로 채워 내보낸다.
+ if (_isDeliverIncomplete && slot.HasLeader && buf is not null
+ && (slot.ExpectedPackets > 0 || (slot.HasTrailer && slot.ExpectedBytes >= 0)))
{
ZeroHoles(slot);
FinalizePayloadSize(slot);
diff --git a/src/GevSharp/Gvsp/GevStream.cs b/src/GevSharp/Gvsp/GevStream.cs
index c18fb7a..4cfca31 100644
--- a/src/GevSharp/Gvsp/GevStream.cs
+++ b/src/GevSharp/Gvsp/GevStream.cs
@@ -10,7 +10,7 @@ namespace GevSharp;
/// GVSP 스트림 수신기. 소켓 하나·수신 스레드 하나로 패킷을 받아 풀 버퍼에 조립하고, 완성된 프레임을 유한 큐로 넘긴다.
/// 시작 순서: 소켓 바인드 → SCDA/SCP → SCPS 플래그 읽기 → 패킷 크기 협상 → SCPS/SCPD → 스레드. AcquisitionStart 는 보내지 않는다(GenApi 쪽 몫).
/// SCPS 는 크기 외에 장치가 켜 둔 플래그(단편화 금지·빅엔디언)를 지키고, Auto 협상은 단편화 금지로 검증했으므로 스트리밍도 같은 조건으로 쓴다.
-/// 정지 순서: SCP = 0, SCDA = 0 → 소켓 닫기(수신 블로킹 해제) → 스레드 합류 → 큐를 으로 닫기.
+/// 정지 순서: SCP = 0, SCDA = 0 → 소켓 닫기(수신 대기 해제) → 스레드 합류 → 큐를 으로 닫기.
///
public sealed partial class GevStream : IAsyncDisposable
{
@@ -24,6 +24,11 @@ public sealed partial class GevStream : IAsyncDisposable
private const int StateStarted = 2;
private const int StateStopping = 3;
private const int StateStopped = 4;
+ ///
+ /// 수신 스레드가 정지 요청 없이 스스로 끝났다(소켓 사망 등). 받기는 이미 "닫힘" 으로 끝나지만 장치 전송 끄기와
+ /// 버퍼 반납은 아직이라 와 다르다 — 정지는 이 상태를 살아 있는 스트림처럼 끝까지 정리한다.
+ ///
+ private const int StateFaulted = 5;
/// 정지가 수신 스레드를 기다리는 상한. 제어 채널의 같은 상한과 맞춘다.
private const int ReceiverJoinMs = 2000;
@@ -41,6 +46,8 @@ public sealed partial class GevStream : IAsyncDisposable
private readonly string _logSrc;
private readonly GevStreamOpt _opt;
private readonly int _channel;
+ /// 정지(와 실패한 시작의 되돌리기)에서 장치 전송을 끄는 쓰기에 주는 고정 예산(ms) — 호출자의 토큰·채널 재시도와 무관하다.
+ private readonly int _shutdownWriteBudgetMs;
private readonly GevFramePool _pool;
private readonly GevStreamStats _stats = new();
private readonly SemaphoreSlim _lifecycle = new(1, 1);
@@ -59,7 +66,12 @@ public sealed partial class GevStream : IAsyncDisposable
/// 수신 옵션. null 이면 기본값. 값 범위가 어긋나면 .
/// 스트림 채널 번호(0 부터).
/// 장치 IPv4 — 방화벽 통과용 한 바이트를 보낼 목적지. null 이면 그 단계를 건너뛴다.
- internal GevStream(IGevPort regs, IGvcpResendPort resend, IPAddress localAddress, GevStreamOpt? opt, int streamChannel = 0, IPAddress? deviceAddress = null)
+ ///
+ /// 정지의 SCP = 0·SCDA = 0(과 실패한 시작의 SCP = 0)이 합쳐서 쓸 수 있는 시간. 장치가 열 때는 를 넘기고,
+ /// 포트 위에 바로 만든 스트림은 그 상한()을 받는다.
+ ///
+ internal GevStream(IGevPort regs, IGvcpResendPort resend, IPAddress localAddress, GevStreamOpt? opt, int streamChannel = 0, IPAddress? deviceAddress = null,
+ int shutdownWriteBudgetMs = GevDevice.CcpReleaseMaxMs)
{
_regs = regs ?? throw new ArgumentNullException(nameof(regs));
_resend = resend ?? throw new ArgumentNullException(nameof(resend));
@@ -71,6 +83,8 @@ internal GevStream(IGevPort regs, IGvcpResendPort resend, IPAddress localAddress
throw new ArgumentException("Local address must be an IPv4 address.", nameof(localAddress));
}
if (streamChannel < 0 || streamChannel > 511) throw new ArgumentOutOfRangeException(nameof(streamChannel));
+ if (shutdownWriteBudgetMs <= 0) throw new ArgumentOutOfRangeException(nameof(shutdownWriteBudgetMs));
+ _shutdownWriteBudgetMs = shutdownWriteBudgetMs;
_opt = opt ?? new GevStreamOpt();
_opt.Validate();
@@ -87,6 +101,16 @@ internal GevStream(IGevPort regs, IGvcpResendPort resend, IPAddress localAddress
public GevStreamStats Stats => _stats;
+ ///
+ /// 스트림이 프레임을 받고 있으면 참 — 가 성공한 뒤부터, ·
+ /// 가 불리거나 수신 스레드가 스스로 끝날 때(스트림 소켓이 죽는 등)까지.
+ ///
+ /// 수신 스레드가 스스로 끝나면 이 값이 거짓이 되고 는 큐에 남은 장을 다 내준 뒤
+ /// 으로 끝난다. 그 스트림은 다시 시작할 수 없고, 정리도 아직이다 —
+ /// (또는 )를 불러야 장치 전송이 꺼지고 버퍼가 돌아온다.
+ /// 장치가 조용해진 것만으로는 수신 스레드가 끝나지 않으므로 그때는 참으로 남는다( 설명 참고).
+ ///
+ ///
public bool IsStarted => Volatile.Read(ref _state) == StateStarted;
/// 프레임을 전달하지 못했을 때 수신 스레드에서 호출된다 — 가볍게 처리해야 한다.
@@ -95,9 +119,12 @@ internal GevStream(IGevPort regs, IGvcpResendPort resend, IPAddress localAddress
/// 테스트용: 인터페이스 MTU 조회를 바꿔 끼운다. null 이면 실제 인터페이스를 본다.
internal Func? MtuResolver { get; set; }
+ /// 테스트용: 스트림 소켓 생성을 바꿔 끼운다 — 핸들 고갈처럼 생성이 던지는 자리를 만든다. null 이면 IPv4 UDP 소켓을 새로 만든다.
+ internal Func? SocketFactory { get; set; }
+
///
/// 소켓을 열고 장치 스트림 채널을 이 소켓으로 향하게 한 뒤 수신 스레드를 띄운다. 두 번 부르면 .
- /// 레지스터 쓰기 실패는 그대로 던지며, 그 경우 소켓은 닫히고 스트림은 정지 상태가 된다.
+ /// 시작이 실패하면(소켓 생성·바인드든 레지스터 쓰기든) 그 예외를 그대로 던지며, 그 경우 소켓은 닫히고 스트림은 정지 상태가 된다.
///
public async Task StartAsync(CancellationToken ct = default)
{
@@ -106,16 +133,22 @@ public async Task StartAsync(CancellationToken ct = default)
{
if (_state != StateNew)
{
- throw new InvalidOperationException(_state == StateStarted || _state == StateStarting
- ? "Stream is already started."
- : "Stream cannot be restarted after it was stopped.");
+ throw new InvalidOperationException(_state switch
+ {
+ StateStarted or StateStarting => "Stream is already started.",
+ StateFaulted => "Stream receiver has already ended on its own; a stream cannot be restarted. Stop it and open a new one.",
+ _ => "Stream cannot be restarted after it was stopped.",
+ });
}
_state = StateStarting;
- var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
+ // 소켓 생성도 아래 try 안에 둔다 — 핸들·버퍼가 바닥나면 생성이 던지는데, 그 예외가 try 밖에서 나면 상태가
+ // "시작 중" 에 걸린 채 남아 다시 시작하려는 쪽은 "이미 시작됨" 을 받고, 정지는 아무것도 쓴 적 없는 장치를 되돌리러 간다.
+ Socket? socket = null;
var hasWrittenScp = false;
try
{
+ socket = SocketFactory?.Invoke() ?? new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
socket.Bind(new IPEndPoint(_localAddress, _opt.LocalPort ?? 0));
socket.ReceiveBufferSize = _opt.SocketBufferBytes;
var granted = socket.ReceiveBufferSize;
@@ -166,20 +199,31 @@ public async Task StartAsync(CancellationToken ct = default)
Priority = _opt.ReceiverPriority,
};
_thread = thread;
+ // 상태는 스레드를 띄우기 **전에** 세운다. 소켓이 곧장 죽으면 수신 스레드가 "시작됨" 을 "스스로 끝남" 으로
+ // 내리는데, 그보다 늦게 여기서 "시작됨" 을 쓰면 죽은 스트림이 시작된 것으로 남는다. 띄우기가 던지면 아래 catch 가 되돌린다.
+ Volatile.Write(ref _state, StateStarted);
thread.Start();
- _state = StateStarted;
- GevLog.Info(_logSrc, $"Stream started on port {LocalPort}, packet size {size}, {_opt.BufferCount} buffers, resend {(_opt.ResendEnabled ? "on" : "off")}.");
+ GevLog.Info(_logSrc, $"Stream started on port {LocalPort}, packet size {size}, {_opt.BufferCount} buffers, resend {(_isResendEnabled ? "on" : "off")}.");
+ if (!_isResendEnabled)
+ {
+ // 비율 0 도 리센드를 끈다 — 옵션만 보고 보존 시간을 늘린 사람이 "왜 안 바뀌나" 를 로그에서 찾을 수 있게 한 번 적는다.
+ GevLog.Info(_logSrc, $"Resend is off (ResendEnabled = {_opt.ResendEnabled}, PacketRequestRatio = {_opt.PacketRequestRatio.ToString(System.Globalization.CultureInfo.InvariantCulture)}); "
+ + $"FrameRetentionMs ({_opt.FrameRetentionMs} ms) does not apply: an incomplete frame is abandoned {_opt.PacketTimeoutMs} ms "
+ + "after its last packet, or as soon as a newer block starts.");
+ }
}
catch
{
_socket = null;
_isStopRequested = true;
- socket.Close();
+ socket?.Close();
if (hasWrittenScp)
{
// 장치가 닫힌 포트로 쏘지 않게 최선을 다해 되돌린다 — 여기서의 실패는 원래 예외를 가리지 않는다.
- try { await WriteRegAsync(GvbsAddr.ScpOffset, 0, CancellationToken.None).ConfigureAwait(false); }
- catch (Exception ex) { GevLog.Warn(_logSrc, "Failed to reset SCP after a failed start.", ex); }
+ // 정지와 같은 고정 예산에 묶는다: 시작이 실패한 까닭이 말없는 장치라면 채널 예산 전부를 한 번 더 쓰게 되고,
+ // 그동안 겹친 정지는 자물쇠 앞에서 함께 기다린다.
+ using var budget = new CancellationTokenSource(_shutdownWriteBudgetMs);
+ await WriteZeroForShutdownAsync(GvbsAddr.ScpOffset, "SCP", "after a failed start", budget.Token).ConfigureAwait(false);
}
_queue?.Complete(new GevStreamClosedException("Stream failed to start."));
_state = StateStopped;
@@ -196,7 +240,22 @@ public async Task StartAsync(CancellationToken ct = default)
/// 장치 전송을 끄고(SCP = 0, SCDA = 0) 소켓을 닫아 수신 스레드를 깨운 뒤 합류한다. 조립 중이던 프레임은 버려지고,
/// 큐에 남은 프레임은 반납되며, 대기 중인 는 으로 끝난다.
/// 여러 번 불러도 된다. 레지스터 쓰기 실패는 로그만 남기고 로컬 정리는 끝까지 진행한다.
+ ///
+ /// 토큰은 정지를 끊지 않는다. 이미 취소된 토큰이 와도, 도중에 취소돼도 위 단계를 전부 밟고 정상으로 돌아온다 —
+ /// 돌아왔다면 스트림은 멈춘 것이다. 반쯤 멈춘 스트림은 온전히 도는 것보다 나쁘다: 장치 전송 끄기를 건너뛰면 장치가
+ /// 닫힌 포트를 향해 계속 쏘고, 로컬 정리를 건너뛰면 큐에 든 버퍼가 돌아오지 않는다. 다 멈춘 뒤에 취소 예외를 던지지도 않는다 —
+ /// 셧다운을 취소 처리로 감싼 호출자는 그 예외 때문에 뒤따르는 정리(장치 닫기 등)를 건너뛰게 되는데, 정작 정지는 끝나 있다.
+ ///
+ ///
+ /// 대신 기다리는 자리마다 상한이 따로 있다. 장치 전송 끄기는 두 쓰기를 합쳐 고정 예산 하나 — 응답 창()
+ /// 두 개, 많아야 2 초 — 안에서 끝난다. 이 예산은 호출자의 토큰에도 에도 기대지 않으므로,
+ /// 장치가 답하지 않게 된 뒤에도(재시도가 끝없는 설정에서도) 정지는 그만큼만 쓰고 돌아온다. 예산이 다하면 경고를 남기고
+ /// 로컬 정리로 넘어간다 — 그때 장치는 옛 SCP·SCDA 를 그대로 들고 있다. 수신 스레드 합류는 2 초를 넘지 않는다.
+ /// 겹친 시작이 자물쇠를 쥐고 있으면 그 시작이 끝나기를 먼저 기다리며, 그 시간은 시작에 준 토큰과 제어 채널의 시한·재시도가 정한다
+ /// (실패한 시작의 SCP 되돌리기는 위와 같은 고정 예산이다).
+ ///
///
+ /// 어떤 단계도 끊지 않는다(위 설명). 취소돼 있어도 정지를 끝까지 하고 정상으로 돌아온다.
public async Task StopAsync(CancellationToken ct = default)
{
var thread = _thread;
@@ -205,7 +264,10 @@ public async Task StopAsync(CancellationToken ct = default)
throw new InvalidOperationException("StopAsync must not be called from the receiver thread (for example inside a FrameDropped handler).");
}
- await _lifecycle.WaitAsync(ct).ConfigureAwait(false);
+ // 토큰을 넘기지 않는다 — SemaphoreSlim 은 이미 취소된 토큰이면 비어 있는 자물쇠도 잡지 않고 곧장 취소로 끝나,
+ // 정지가 아무 일도 하지 않은 채 돌아간다. 겹친 시작이 자물쇠를 쥔 자리에서도 마찬가지로, 그 시작이 끝난 뒤 스트림이
+ // 그대로 돌게 된다. 쥐는 쪽은 전부 상한이 있으므로(위 설명) 이 대기도 끝난다.
+ await _lifecycle.WaitAsync(CancellationToken.None).ConfigureAwait(false);
try
{
if (_state == StateStopped) return;
@@ -217,10 +279,16 @@ public async Task StopAsync(CancellationToken ct = default)
_state = StateStopping;
_isStopRequested = true;
- try { await WriteRegAsync(GvbsAddr.ScpOffset, 0, ct).ConfigureAwait(false); }
- catch (Exception ex) { GevLog.Warn(_logSrc, "Failed to write SCP = 0 while stopping the stream.", ex); }
- try { await WriteRegAsync(GvbsAddr.ScdaOffset, 0, ct).ConfigureAwait(false); }
- catch (Exception ex) { GevLog.Warn(_logSrc, "Failed to write SCDA = 0 while stopping the stream.", ex); }
+ // 장치 전송 끄기는 호출자의 토큰과 무관하게 시도한다(실패한 시작의 되돌리기와 같다). 토큰을 넘기면 SCP 쓰기 도중의
+ // 취소가 "SCP 쓰기 실패" 로 기록되고, 이미 취소된 토큰을 받은 SCDA 쓰기는 보내지도 못한 채 끝난다.
+ // 그렇다고 채널의 재시도 예산 전부를 쓰지도 않는다 — 두 쓰기가 합쳐서 고정 예산 하나를 받는다. 채널 예산에 기대면 말없는
+ // 장치 앞에서 정지가 쓰기 둘 × (1 + GvcpRetries) × 응답 창만큼(재시도 중인 하트비트 뒤의 줄서기까지) 붙들리는데 호출자는
+ // 그것을 끊을 길이 없고, 재시도가 끝없으면 정지가 돌아오지 않는다(응답 창 500 ms·재시도 20 회에서 30 초 안에 돌아오지 않았다).
+ using (var budget = new CancellationTokenSource(_shutdownWriteBudgetMs))
+ {
+ await WriteZeroForShutdownAsync(GvbsAddr.ScpOffset, "SCP", "while stopping the stream", budget.Token).ConfigureAwait(false);
+ await WriteZeroForShutdownAsync(GvbsAddr.ScdaOffset, "SCDA", "while stopping the stream", budget.Token).ConfigureAwait(false);
+ }
var socket = _socket;
_socket = null;
@@ -230,7 +298,7 @@ public async Task StopAsync(CancellationToken ct = default)
_thread = null;
if (thread is not null && thread.IsAlive)
{
- // 상한 없이 기다리지 않는다. 소켓을 닫으면 블로킹 수신이 깨어나는 것이 보통이지만 그것을 보장하는 규격은 없고,
+ // 상한 없이 기다리지 않는다. 소켓을 닫으면 수신 대기(Poll)가 깨어나는 것이 보통이지만 그것을 보장하는 규격은 없고,
// 여기서 무한히 기다리면 정지가 영영 돌아오지 않는다. 게다가 이 대기는 스레드풀 스레드를 하나 붙들고 있어서,
// 코어가 적은 기계에서 정지가 몇 개 겹치면 풀이 고갈된다. 제어 채널은 이미 같은 상한을 두고 있다.
// 시한을 넘겨도 할 일은 그대로 한다 — 소켓은 이미 닫혔고 아래에서 큐를 비워 버퍼를 돌려준다.
@@ -262,7 +330,8 @@ public async Task StopAsync(CancellationToken ct = default)
}
///
- /// 다음 프레임을 기다린다. 시작 전이거나 정지된 스트림이면 .
+ /// 다음 프레임을 기다린다. 시작 전이거나 정지된 스트림이면 — 수신 스레드가
+ /// 스스로 끝난 스트림( 참고)도 큐에 남은 장을 다 내준 뒤 같은 예외로 끝난다.
/// 받은 프레임은 반드시 Dispose 한다.
///
/// 장치가 사라져도 이 대기는 스스로 끝나지 않는다. 장치는 자기가 연 스트림을 모르므로 제어 상실
@@ -517,6 +586,13 @@ private bool SendPunch(Socket socket)
///
internal void SimulateReceiverQueueCompletion(Exception cause) => _queue?.Complete(cause);
+ ///
+ /// 정지 요청 없이 스트림 소켓을 닫는다 — NIC 가 내려가 소켓이 죽은 것과 같은 자리를 만들어, 수신 스레드가
+ /// 스스로 끝나는 실제 경로(대기·수신 실패 → 루프 종료 → 큐 닫기)를 밟게 한다. 어느 갈래로 오는지는 플랫폼이 정한다 —
+ /// 대기(Poll)가 예외를 내면 수신 오류 분류로, 참을 돌려주면 다음 수신의 ObjectDisposedException 으로 온다.
+ ///
+ internal void KillSocketForTest() => _socket?.Close();
+
/// 조립이 끝나 큐에 든 프레임을 동기로 꺼낸다 — 할당 계측이 비동기 대기의 할당에 섞이지 않게.
internal bool TryDrainForTest(out GevFrame frame)
{
@@ -551,6 +627,32 @@ internal void FeedPacketForTest(byte[] packet, int length)
OnPacket(length, System.Diagnostics.Stopwatch.GetTimestamp());
}
+ ///
+ /// 정지(와 실패한 시작의 되돌리기)에서 장치 전송을 끄는 0 쓰기 하나. 은 호출자의 토큰이 아니라 그 자리에서 만든
+ /// 고정 예산이다. 실패는 로그만 남기고 삼킨다 — 로컬 정리는 이 결과와 무관하게 끝까지 가야 한다.
+ /// 앞선 쓰기가 예산을 다 썼으면 보내지 않고 그렇다고 적는다(이미 취소된 토큰으로 부르면 채널은 보내지도 않고 취소로 끝난다).
+ ///
+ private async Task WriteZeroForShutdownAsync(uint offset, string register, string during, CancellationToken budget)
+ {
+ if (budget.IsCancellationRequested)
+ {
+ GevLog.Warn(_logSrc, $"Skipped writing {register} = 0 {during}: the {_shutdownWriteBudgetMs} ms budget for turning the device's transmission off was already spent.");
+ return;
+ }
+ try
+ {
+ await WriteRegAsync(offset, 0, budget).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (budget.IsCancellationRequested)
+ {
+ GevLog.Warn(_logSrc, $"Writing {register} = 0 {during} got no answer within the {_shutdownWriteBudgetMs} ms budget; giving up so the local cleanup is not held.");
+ }
+ catch (Exception ex)
+ {
+ GevLog.Warn(_logSrc, $"Failed to write {register} = 0 {during}.", ex);
+ }
+ }
+
private ValueTask WriteRegAsync(uint offset, uint value, CancellationToken ct)
{
var bytes = new byte[4];
diff --git a/src/GevSharp/Gvsp/GevStreamOpt.cs b/src/GevSharp/Gvsp/GevStreamOpt.cs
index 6b9b62b..5cbd837 100644
--- a/src/GevSharp/Gvsp/GevStreamOpt.cs
+++ b/src/GevSharp/Gvsp/GevStreamOpt.cs
@@ -20,15 +20,34 @@ public sealed class GevStreamOpt
/// 수신 소켓 버퍼 요청 크기. OS 가 실제로 준 값은 시작 시 로그로 남는다.
public int SocketBufferBytes { get; set; } = 32 * 1024 * 1024;
+ ///
+ /// 빠진 패킷의 리센드를 요청할지. 거짓이면(또는 = 0 이면) 요청하지 않고, 기다릴 리센드가 없으므로
+ /// 불완전 프레임은 마지막 패킷 뒤 에 포기하며 더 새로운 블록이 시작되면 곧바로 닫는다 —
+ /// 는 쓰이지 않는다(시작할 때 Info 로그 한 줄이 그렇게 알린다).
+ ///
public bool ResendEnabled { get; set; } = true;
/// 구멍을 처음 본 뒤 첫 리센드 요청까지 기다리는 시간 — 순서 바뀐 패킷이 스스로 도착할 여유.
public int InitialPacketTimeoutMs { get; set; } = 2;
- /// 같은 구멍에 대한 리센드 재요청 간격. 수신 루프의 주기적 점검 간격이기도 하다.
+ ///
+ /// 같은 구멍에 대한 리센드 재요청 간격. 이만큼 아무것도 오지 않으면 장치가 그 프레임을 다 보낸 것으로 보고 아직 안 온 꼬리도 구멍으로 친다.
+ /// 더 요청할 것이 없는 프레임(리센드 꺼짐, 요청 예산 소진, 장치가 못 준다고 답함)은 마지막 패킷 뒤 이 시간에 포기한다.
+ /// 수신 루프가 깨어나는 간격은 이 값이 아니다 — 조립 중인 프레임이 있으면 max(1, min(, )) ms,
+ /// 없으면 200 ms 마다 깨어나 구멍과 시한을 본다(패킷이 흐르는 동안은 패킷마다 본다).
+ ///
public int PacketTimeoutMs { get; set; } = 20;
- /// 마지막 패킷 도착 후 이 시간이 지나도록 완성되지 않은 프레임은 포기한다.
+ ///
+ /// 마지막 패킷 도착 후 이 시간이 지나도록 완성되지 않은 프레임은 포기한다 — 리센드를 아직 묻고 있는 프레임에만 쓰인다.
+ /// 하나 더: 리센드가 켜져 있으면 다른 이유(버퍼 없음·다루지 않는 형식·오류)로 버리기로 한 프레임도 트레일러가 오지 않는 한
+ /// 이 시간까지 조립 슬롯을 쥐고, 그 프레임의 도 그때 올라간다.
+ /// 리센드가 꺼져 있으면( = false 또는 = 0) 이 값은 쓰이지 않는다:
+ /// 불완전 프레임은 마지막 패킷 뒤 에 포기되고, 더 새로운 블록이 시작되면 곧바로 닫힌다.
+ /// 버리기로 한 프레임도 트레일러가 없으면 마지막 패킷 뒤 에 닫힌다.
+ /// 예산을 다 썼거나 장치가 못 준다고 답한 프레임도 에 닫힌다.
+ /// 리더만 받은 가장 새 프레임은 예외로 이 시간이 지나도 기다린다(노출이 긴 촬영에서 리더가 먼저 오는 장치가 있다).
+ ///
public int FrameRetentionMs { get; set; } = 100;
///
@@ -36,10 +55,16 @@ public sealed class GevStreamOpt
/// 같은 구멍을 다시 묻는 재요청은 이 상한을 쓰지 않는다 — 재요청은 간격으로 까지만 되풀이되므로 그 자체로 유한하다.
/// 장치가 프레임 도중 만큼 쉬어 꼬리를 침묵으로 짐작한 경우에는 이 상한을 넘겨도 프레임을 포기하지 않는다 —
/// 상한 안에 들어가는 앞부분만 묻고, 장치가 이어 보내면 프레임은 그대로 완성된다.
+ /// 0 은 리센드를 끈다( = false 와 같고 도 쓰이지 않는다). 0 보다 크면 아무리 작아도
+ /// 프레임마다 적어도 한 패킷은 요청할 수 있다.
///
public double PacketRequestRatio { get; set; } = 0.25;
- /// 참이면 포기한 프레임도 = false 로 전달한다(빠진 영역은 0 으로 채운다). 기본은 버리고 세기만 한다.
+ ///
+ /// 참이면 포기한 프레임도 = false 로 전달한다(빠진 영역은 0 으로 채운다). 기본은 버리고 세기만 한다.
+ /// 하나 예외: 리더만 받은 프레임을 장치가 버리고 같은 블록 번호로 촬영을 다시 시작하면, 받은 이미지 바이트가 없는 옛 프레임은
+ /// 전달하지 않고 불완전 통계와 로만 알린다.
+ ///
public bool DeliverIncompleteFrames { get; set; } = false;
///
diff --git a/src/GevSharp/IGevPort.cs b/src/GevSharp/IGevPort.cs
index 134d641..806eef6 100644
--- a/src/GevSharp/IGevPort.cs
+++ b/src/GevSharp/IGevPort.cs
@@ -2,7 +2,8 @@ namespace GevSharp;
///
/// 레지스터 공간 접근 경계 — GenApi 계층이 전송을 아는 유일한 지점. 구현체는 GVCP 장치이거나 테스트용 메모리 모델이다.
-/// 주소는 GenApi 규약대로 64비트지만 GVCP 는 32비트 주소만 나른다 — 범위를 벗어나면 구현이 예외를 낸다.
+/// 주소는 GenApi 규약대로 64비트지만 GVCP 는 32비트 주소만 나른다. GVCP 장치 구현()은
+/// 32비트를 넘는 주소를 하위 32비트로 좁혀 쓰고(주소마다 경고 한 번), 좁힌 접근의 끝이 32비트 공간을 넘을 때만 예외를 낸다.
/// 바이트 순서 해석은 호출자(GenApi 노드) 몫이며, 포트는 원시 바이트만 옮긴다.
///
public interface IGevPort
diff --git a/src/GevSharp/Xml/GevXmlLoader.cs b/src/GevSharp/Xml/GevXmlLoader.cs
index 777bc73..fbac3aa 100644
--- a/src/GevSharp/Xml/GevXmlLoader.cs
+++ b/src/GevSharp/Xml/GevXmlLoader.cs
@@ -1,5 +1,6 @@
using System.IO.Compression;
using System.Reflection;
+using System.Runtime.ExceptionServices;
using System.Text;
using GevSharp.Gvcp;
@@ -19,6 +20,12 @@ public static class GevXmlLoader
/// http(s) 내려받기 한 번의 전체 시한.
public const int HttpTimeoutMs = 10_000;
+ ///
+ /// http 내려받기가 를 넘겨 난 의 에 이 키로
+ /// true 를 넣는다. 형은 GVCP 무응답과 같지만 장치와 무관한 서버 쪽 시한 초과라, 장치 상실 판정()이 이 표식으로 가른다.
+ ///
+ internal const string HttpTimeoutKey = "HttpTimeout";
+
///
/// XML(또는 ZIP) 한 개의 크기 상한. Local: 의 선언 길이, File: 의 파일 크기, http(s) 응답 본문(선언된 길이든 실제 수신량이든),
/// ZIP 항목의 선언 크기와 실제 압축 해제량 모두 이 값을 넘으면 메모리에 쌓기 전에 거부한다 — 장치가 준 값 하나로 호스트가 거대 할당을 하지 않게.
@@ -40,7 +47,19 @@ public static class GevXmlLoader
///
/// First URL(0x0200) 을 읽어 XML 을 가져오고, 읽기·해석·가져오기 어느 단계든 실패하면 Second URL(0x0400) 로 넘어간다.
/// Second URL 이 First URL 과 같은 문자열이면 같은 실패를 되풀이할 뿐이라(HTTP 시한·긴 메모리 읽기가 두 배가 된다) 다시 시도하지 않는다.
- /// 둘 다 실패하면 두 사유를 모두 실은 . 취소는 그대로 전파된다.
+ ///
+ /// 던지는 것 — 호출자가 형으로 "다시 연결" 과 "XML 이 틀렸다" 를 가를 수 있게 원래 형을 지킨다:
+ /// 포트가 장치를 잃었다고 알리면(, ,
+ /// — URL 레지스터 읽기, 캐시를 켰을 때의 캐시 키 읽기, Local: 메모리 읽기 어디서든) 다른 URL 로 넘어가지 않고 그 예외를
+ /// 감싸지 않은 채 그대로 던진다(다른 URL 도 같은 포트를 거쳐 재시도 예산만 한 번 더 쓴다). 두 번째 시도에서 잃었어도 같다.
+ /// 시한 초과라도 상대가 살아 있던 것은 여기에 들지 않는다 — 다른 URL 로 넘어간다: http 내려받기의 시한 초과(장치가 아니라 서버 쪽 사정),
+ /// 장치가 PENDING_ACK 로 "받아서 실행 중" 이라고 답한 뒤 허락된 연장 안에 끝내지 못한 읽기.
+ /// 그 밖에 시도한 URL 이 모두 같은 구체 형(예: 둘 다 )으로 실패했으면 첫 실패를 그대로 던지고,
+ /// 종류가 다르면(빈 레지스터 포함) 두 사유를 모두 실은 (마지막 실패가 InnerException).
+ /// 다만 상대가 살아 있던 시한 초과는 시도한 URL 이 모두 그것으로 실패했어도 모은 으로 낸다 —
+ /// 그래서 이 메서드가 감싸지 않고 던지는 은 언제나 장치 상실(응답 없는 GVCP 요청)이다.
+ /// 취소는 그대로 전파된다.
+ ///
///
public static Task LoadAsync(IGevPort port, string? cacheDir = null, CancellationToken ct = default)
=> LoadAsync(port, cacheDir, null, ct);
@@ -56,7 +75,10 @@ internal static async Task LoadAsync(IGevPort port, string? cacheDir,
if (port is null) throw new ArgumentNullException(nameof(port));
var failures = new List(2);
- Exception? last = null;
+ var errors = new List(2);
+ // 예외 없이 끝난 실패(빈 레지스터)가 있었는지 — 그것도 한 종류라 "모두 같은 종류" 판정에 낀다.
+ // First URL 과 같아서 다시 시도하지 않은 Second URL 은 첫 실패와 같은 실패라 따로 세지 않는다.
+ var hadEmptyRegister = false;
string? firstRaw = null;
for (var i = 0; i < 2; i++)
{
@@ -70,6 +92,7 @@ internal static async Task LoadAsync(IGevPort port, string? cacheDir,
var raw = await ReadUrlRegisterAsync(port, regAddr, ct).ConfigureAwait(false);
if (raw.Length == 0)
{
+ hadEmptyRegister = true;
failures.Add($"{regName}: register is empty");
GevLog.Debug(logSrc ?? LogSrc, $"{regName} register is empty.");
continue;
@@ -90,9 +113,14 @@ internal static async Task LoadAsync(IGevPort port, string? cacheDir,
{
throw;
}
+ catch (Exception ex) when (IsDeviceLoss(ex))
+ {
+ GevLog.Warn(logSrc ?? LogSrc, $"Lost the device while reading the {regName} register; not trying the other URL: {ex.Message}", ex);
+ throw;
+ }
catch (Exception ex)
{
- last = ex;
+ errors.Add(ex);
failures.Add($"{regName}: {ex.Message}");
GevLog.Warn(logSrc ?? LogSrc, $"Could not read or parse the {regName} register: {ex.Message}", ex);
continue;
@@ -106,20 +134,71 @@ internal static async Task LoadAsync(IGevPort port, string? cacheDir,
{
throw;
}
+ catch (Exception ex) when (IsDeviceLoss(ex))
+ {
+ GevLog.Warn(logSrc ?? LogSrc, $"Lost the device while loading the camera XML from the {regName} '{url.Raw}'; not trying the other URL: {ex.Message}", ex);
+ throw;
+ }
catch (Exception ex)
{
- last = ex;
+ errors.Add(ex);
failures.Add($"{regName} '{url.Raw}': {ex.Message}");
GevLog.Warn(logSrc ?? LogSrc, $"Could not load the camera XML from the {regName} '{url.Raw}': {ex.Message}", ex);
}
}
- throw new GevException("Could not load the camera XML from the First/Second URL registers: " + string.Join(" | ", failures), last);
+ // 시도한 URL 이 모두 같은 구체 형으로 실패했으면 그 형이 곧 답이다 — 첫 실패를 스택까지 그대로 던진다(다른 사유는 경고 로그에 있다).
+ // 형이 GevException 그 자체면 두 사유를 합쳐도 형이 같으므로 합친 쪽을 낸다. 시한 초과도 합친 쪽이다(IsSameSpecificKind).
+ if (!hadEmptyRegister && IsSameSpecificKind(errors))
+ ExceptionDispatchInfo.Capture(errors[0]).Throw();
+
+ throw new GevException(
+ "Could not load the camera XML from the First/Second URL registers: " + string.Join(" | ", failures),
+ errors.Count == 0 ? null : errors[errors.Count - 1]);
+ }
+
+ ///
+ /// 포트가 장치를 잃었다는 실패인지 — 제어 상실, 응답 없는 시한 초과, 해제된 장치. 이런 실패 뒤에는 다른 URL 도 같은 포트를 거쳐
+ /// 같은 이유로 실패하므로 넘어가지 않고 원래 형 그대로 던진다.
+ /// 시한 초과라도 상대가 살아 있던 것은 상실이 아니다 — http 서버가 답하지 않은 내려받기( 표식),
+ /// 장치가 PENDING_ACK 로 답한 뒤 연장 안에 끝내지 못한 요청( 표식).
+ /// 어느 단계(URL 종류)에서 났는지가 아니라 예외에 붙은 표식으로 가른다: http URL 을 적재하는 중에도 캐시 키는 장치에서 읽으므로,
+ /// URL 종류로 가르면 그 자리의 GVCP 무응답을 서버 탓으로 잘못 읽는다. 표식이 없는 시한 초과는 상실로 본다
+ /// (표식을 달지 않는 포트 구현도 같은 판정을 받는다).
+ ///
+ internal static bool IsDeviceLoss(Exception ex)
+ => ex is GevControlLostException
+ || ex is ObjectDisposedException
+ || (ex is GevTimeoutException
+ && ex.Data[HttpTimeoutKey] is not true
+ && ex.Data[GvcpChannel.PendingAckExpiredKey] is not true);
+
+ // 예외가 하나 이상이고 전부 GevException 보다 구체적인 같은 형인지.
+ // 시한 초과는 같은 형이어도 맨몸으로 내지 않는다 — 이 메서드가 감싸지 않고 던지는 GevTimeoutException 은 호출자에게
+ // "장치를 잃었다(다시 연결)" 는 뜻이다. 상실인 시한 초과는 그 자리에서 이미 던졌으므로 여기 모인 것은 상대가 살아 있던
+ // 시한 초과(http 서버 무응답, PENDING_ACK 연장 소진)뿐이고, 그것이 맨몸으로 나가면 멀쩡한 장치를 다시 연결하게 만든다.
+ private static bool IsSameSpecificKind(List errors)
+ {
+ if (errors.Count == 0) return false;
+ var type = errors[0].GetType();
+ if (type == typeof(GevException) || type == typeof(GevTimeoutException)) return false;
+ foreach (var e in errors)
+ {
+ if (e.GetType() != type) return false;
+ }
+ return true;
}
///
/// 해석된 URL 하나로 XML 을 가져온다(First/Second 폴백 없음). cacheDir 가 있으면 장치 식별 문자열로 캐시 파일을 찾고,
- /// 적중하면 XML 본문 전송 없이 캐시 텍스트를 돌려준다. 캐시 읽기·쓰기 실패는 경고 로그로만 남고 결과에는 영향이 없다.
+ /// 적중하면 XML 본문 전송 없이 캐시 텍스트를 돌려준다. 캐시 키 읽기와 캐시 파일 읽기·쓰기의 실패는 경고 로그로만 남고 결과에는 영향이 없다.
+ /// 가져오기 실패는 사유와 출처를 실은 이되, 캐시 키 읽기나 Local: 메모리 읽기 중 포트가 장치를 잃었다고 알린
+ /// ·· 은 감싸지 않고 그대로 던진다
+ /// (캐시 키에서 잃었으면 캐시 없이 이어 가지 않는다 — 다음 읽기가 재시도 예산을 한 번 더 쓰고 같은 이유로 실패할 뿐이다).
+ /// 장치가 PENDING_ACK 로 답한 뒤 연장 안에 끝내지 못한 읽기는 장치를 잃은 것이 아니다 — 캐시 키에서면 캐시 없이 이어 가고,
+ /// Local: 메모리에서면 다른 읽기 실패처럼 감싼다.
+ /// http 내려받기가 시한을 넘기면 — URL 하나만 다루는 이 메서드에서는 감싸지 않고 나오므로,
+ /// 장치 상실과는 메시지로 가른다(두 URL 을 시도하는 는 이것을 감싸 낸다).
///
public static Task LoadFromUrlAsync(IGevPort port, GevXmlUrl url, string? cacheDir = null, CancellationToken ct = default)
=> LoadFromUrlAsync(port, url, cacheDir, null, ct);
@@ -228,6 +307,11 @@ private static async Task ReadDeviceMemoryAsync(IGevPort port, GevXmlUrl
{
throw;
}
+ catch (Exception ex) when (IsDeviceLoss(ex))
+ {
+ // 장치 상실은 파일 이름을 붙여 감싸지 않는다 — 형이 곧 호출자의 판단 근거다(다시 연결).
+ throw;
+ }
catch (Exception ex)
{
throw new GevException($"Failed to read camera XML '{url.FileName}' from device memory at 0x{url.Address:X8} ({url.Length} bytes): {ex.Message}", ex);
@@ -297,7 +381,10 @@ private static async Task DownloadAsync(GevXmlUrl url, string? logSrc, C
catch (OperationCanceledException)
{
// 호출자가 취소하지 않았는데 취소 예외가 났다면 HttpClient.Timeout 이 끊은 것이다.
- throw new GevTimeoutException($"Downloading camera XML from '{uri}' timed out after {HttpTimeoutMs} ms.");
+ // 장치가 아니라 서버가 답하지 않은 것이라 장치 상실로 읽히지 않게 표식을 단다.
+ var timeout = new GevTimeoutException($"Downloading camera XML from '{uri}' timed out after {HttpTimeoutMs} ms.");
+ timeout.Data[HttpTimeoutKey] = true;
+ throw timeout;
}
catch (GevException)
{
@@ -402,6 +489,12 @@ private static bool HasZipMagic(byte[] bytes)
{
throw;
}
+ catch (Exception ex) when (IsDeviceLoss(ex))
+ {
+ // 장치를 잃었으면 캐시 없이 이어 가도 다음 읽기가 재시도 예산을 한 번 더 다 쓰고 같은 이유로 실패한다 — 여기서 멈춘다.
+ // 캐시 키가 없어서 버리는 것이 아니라 적재 자체가 끝난 것이라, 경고는 부르는 쪽이 한 번만 남긴다.
+ throw;
+ }
catch (Exception ex)
{
GevLog.Warn(logSrc ?? LogSrc, $"Could not build the XML cache key from the bootstrap registers; continuing without cache: {ex.Message}", ex);
diff --git a/tests/GevSharp.Sim/Assets/SimCamera.xml b/tests/GevSharp.Sim/Assets/SimCamera.xml
index 644192d..2e106cf 100644
--- a/tests/GevSharp.Sim/Assets/SimCamera.xml
+++ b/tests/GevSharp.Sim/Assets/SimCamera.xml
@@ -46,6 +46,7 @@
DeviceSerialNumber
DeviceUserID
TimestampTickFrequency
+ TimestampReset
TimestampLatch
TimestampLatchValue
@@ -106,12 +107,22 @@
BigEndian
-
- Latch the timestamp counter into TimestampLatchValue (bootstrap 0x0944 = 1)
+
+
+ Restart the timestamp counter at 0 (bootstrap 0x0944 = 1); the latched value is left as it was
TimestampControlReg
1
+
+ Latch the timestamp counter into TimestampLatchValue (bootstrap 0x0944 = 2)
+ TimestampControlReg
+ 2
+
+
0x944
4
@@ -134,6 +145,7 @@
Device
NoCache
TimestampLatch
+ GevTimestampControlLatch
Unsigned
BigEndian
@@ -226,7 +238,8 @@
- Horizontal offset of the region of interest
+ Horizontal offset of the region of interest; locked while acquiring
+ AcquisitionActive
Yes
OffsetXReg
0
@@ -244,7 +257,8 @@
- Vertical offset of the region of interest
+ Vertical offset of the region of interest; locked while acquiring
+ AcquisitionActive
Yes
OffsetYReg
0
@@ -331,7 +345,8 @@
- Horizontal flip flag (register round-trip only; the simulator pattern is not mirrored)
+ Horizontal flip flag (register round-trip only; the simulator pattern is not mirrored); locked while acquiring
+ AcquisitionActive
Yes
ReverseXReg
1
@@ -368,7 +383,8 @@
- Continuous, SingleFrame or MultiFrame
+ Continuous, SingleFrame or MultiFrame; locked while acquiring
+ AcquisitionActive
Yes
0
@@ -433,7 +449,7 @@
- 1 while the device is acquiring; used as the lock predicate of Width, Height and PixelFormat
+ 1 while the device is acquiring; the lock predicate of AcquisitionMode, Width, Height, OffsetX, OffsetY, PixelFormat and ReverseX
Guru
AcquisitionActiveReg
Boolean
@@ -751,6 +767,10 @@
GevCCP
GevSCCExtendedIds
GevSCCFGExtendedIds
+ GevTimestampTickFrequency
+ GevTimestampControlReset
+ GevTimestampControlLatch
+ GevTimestampValue
TLParamsLocked
@@ -898,6 +918,31 @@
BigEndian
+
+ Timestamp counter frequency in Hz (bootstrap 0x093C, 64-bit); same register as TimestampTickFrequency
+ TimestampTickFrequencyReg
+ Hz
+ PureNumber
+
+
+
+ Restart the timestamp counter at 0 (bootstrap 0x0944 = 1); same as TimestampReset
+ TimestampControlReg
+ 1
+
+
+
+ Latch the timestamp counter into GevTimestampValue (bootstrap 0x0944 = 2); same as TimestampLatch
+ TimestampControlReg
+ 2
+
+
+
+ Latched timestamp in ticks (bootstrap 0x0948, 64-bit); same register as TimestampLatchValue
+ TimestampLatchValueReg
+ PureNumber
+
+
Acquisition commands stay locked until the host has configured the stream channel and set TLParamsLocked.
Invisible
diff --git a/tests/GevSharp.Sim/SimDevice.Gvcp.cs b/tests/GevSharp.Sim/SimDevice.Gvcp.cs
index 3c231d0..74bf378 100644
--- a/tests/GevSharp.Sim/SimDevice.Gvcp.cs
+++ b/tests/GevSharp.Sim/SimDevice.Gvcp.cs
@@ -41,17 +41,21 @@ private void GvcpLoop()
{
if (!sock.Poll(20_000, SelectMode.SelectRead))
{
- CheckHeartbeat();
+ lock (_commandGate) CheckHeartbeat();
continue;
}
int n = sock.ReceiveFrom(buf, ref ep);
var src = (IPEndPoint)ep;
var sender = new IPEndPoint(src.Address, src.Port);
- CheckHeartbeat();
- long handleStartNs = NowNs;
- HandleGvcp(buf, n, sender);
- ObserveCommandHandleTime(NowNs - handleStartNs);
+ // 명령 하나는 재부팅(Reboot)과 겹치지 않는다 — 처리 시간은 잠금을 얻은 뒤부터 잰다(재부팅을 기다린 시간은 빼고).
+ lock (_commandGate)
+ {
+ CheckHeartbeat();
+ long handleStartNs = NowNs;
+ HandleGvcp(buf, n, sender);
+ ObserveCommandHandleTime(NowNs - handleStartNs);
+ }
}
catch (ObjectDisposedException)
{
@@ -457,10 +461,11 @@ private void ApplySideEffect(uint addr, uint value)
break;
case GvbsAddr.TimestampControl:
- // 값(LSB 기준): 2 = reset, 1 = latch. 쓰기 전용 성격이라 읽으면 0.
+ // 값(LSB 기준): 1 = reset, 2 = latch — 실제 장치의 기술이 GevTimestampControlReset/Latch 에 싣는 CommandValue 와 같다.
+ // 쓰기 전용 성격이라 읽으면 0. 둘 다 서 있으면 reset 뒤에 latch(래치 값은 거의 0).
Registers.WriteU32(addr, 0);
- if ((value & 2) != 0) Volatile.Write(ref _timestampBaseNs, NowNs);
- if ((value & 1) != 0)
+ if ((value & 1) != 0) Volatile.Write(ref _timestampBaseNs, NowNs);
+ if ((value & 2) != 0)
{
ulong ts = TimestampTicks;
Registers.WriteU32(GvbsAddr.TimestampLatchedHigh, (uint)(ts >> 32));
diff --git a/tests/GevSharp.Sim/SimDevice.cs b/tests/GevSharp.Sim/SimDevice.cs
index fc30a84..1ed66e6 100644
--- a/tests/GevSharp.Sim/SimDevice.cs
+++ b/tests/GevSharp.Sim/SimDevice.cs
@@ -18,6 +18,11 @@ public sealed partial class SimDevice : IDisposable
private static readonly Lazy _embeddedXml = new(LoadEmbeddedXml);
private readonly object _gate = new();
+ ///
+ /// GVCP 명령 하나의 처리(하트비트 만료 검사 포함)와 를 서로 배제한다 — 재부팅이 명령 한가운데에 끼어
+ /// 반쯤 되돌린 상태를 명령이 보거나, 명령이 되돌린 값을 다시 덮는 일이 없게 한다.
+ ///
+ private readonly object _commandGate = new();
private readonly Stopwatch _clock = Stopwatch.StartNew();
private readonly double _nsPerClockTick = 1_000_000_000.0 / Stopwatch.Frequency;
private readonly byte[] _mac = new byte[6];
@@ -140,7 +145,12 @@ public IPEndPoint? ControlOwner
public bool IsAcquiring => Registers.ReadU32(SimFeatureAddr.AcquisitionStatus) != 0;
- /// 제어권 보유자가 바뀔 때(획득·해제·타임아웃). 서버 스레드에서 호출된다.
+ ///
+ /// 제어권 보유자가 바뀔 때(획득·해제·타임아웃·). 획득·해제·타임아웃은 서버 스레드에서, 재부팅이 비운 것(null)은
+ /// 를 부른 스레드에서 호출된다. 어느 쪽이든 GVCP 명령 처리와 같은 잠금 안에서 올라가므로 관찰자는 바뀐 순서대로
+ /// 받는다 — 재부팅의 null 은 그 뒤에 잡은 새 보유자보다 항상 먼저다. 그 잠금 안이라 처리기가 이 장치의 GVCP 응답을 기다리면
+ /// 그동안 응답기도 멈춘다(처리기가 끝나야 다음 명령을 처리한다).
+ ///
public event Action? ControlOwnerChanged;
/// 프레임 하나의 전송이 끝났을 때(블록 ID). 송신 스레드에서 호출된다.
@@ -185,7 +195,10 @@ public void Start()
thread.Start();
}
- /// 획득을 멈추고 소켓을 닫고 스레드를 거둔다. 여러 번 불러도 된다. 레지스터 내용은 남는다.
+ ///
+ /// 획득을 멈추고 소켓을 닫고 스레드를 거둔다. 여러 번 불러도 된다. 레지스터 내용과 제어권 보유자는 남는다 —
+ /// 장치 재시작을 흉내 내려면 를 쓴다.
+ ///
public void Stop()
{
_isStopping = true;
@@ -207,6 +220,46 @@ public void Stop()
public void Dispose() => Stop();
+ ///
+ /// 전원을 껐다 켠 장치를 흉내 낸다. 소켓(엔드포인트)은 그대로 두고 휘발 상태만 켜진 직후로 되돌린다 —
+ /// 획득 정지, 제어권(보유자·CCP·PrimaryApp), 하트비트 타임아웃(), GVCP 설정,
+ /// 스트림 채널 0(SCP·SCPS·SCPD·SCDA·SCCFG), 타임스탬프 카운터(0 부터)와 래치 값, 피처 페이지(),
+ /// 블록 ID(다음 프레임은 1), 리센드 이력, 무장된 소프트웨어 트리거.
+ /// 남는 것: 식별·영속 IP·사용자 이름 같은 비휘발 레지스터, 관찰용 카운터와 FrameCounter 레지스터(시뮬레이터의 생애를 센다).
+ ///
+ /// 처리 중인 GVCP 명령이 끝난 뒤, 다음 명령 전에 한꺼번에 일어난다. 보유자가 있었으면 (null) 이
+ /// 이 메서드를 부른 스레드에서, 다음 명령이 처리되기 전에 한 번 올라간다. 호스트 쪽에서는 다음 하트비트가 CCP = 0 을 읽어 제어권 상실(장치 재시작 계열 사유)을 알리고, 새 세션이 기다림 없이
+ /// 제어권을 잡는다. 꺼져 있는 동안의 공백(응답하지 않는 시간)은 흉내 내지 않는다.
+ ///
+ ///
+ /// / 는 재부팅이 아니다 — 제어권과 레지스터가 그대로 남아 같은 호스트가 계속 보유자로 보이고,
+ /// 임시 포트( = 0)면 다시 시작할 때 포트까지 바뀐다.
+ ///
+ ///
+ public void Reboot()
+ {
+ lock (_commandGate)
+ {
+ StopAcquisition(join: true);
+ IPEndPoint? previousOwner;
+ lock (_gate)
+ {
+ previousOwner = _owner;
+ _owner = null;
+ }
+ ResetVolatileBootstrap();
+ Volatile.Write(ref _timestampBaseNs, NowNs);
+ Volatile.Write(ref _blockId, 0UL);
+ Interlocked.Exchange(ref _softwareTriggerPending, 0);
+ lock (_history) _history.Clear();
+ ResetFeatures();
+ // 명령 잠금 안에서 올린다 — 서버 스레드의 획득·해제·만료 알림도 같은 잠금 안에서 올라가므로, 이 null 이 끝나기 전에는
+ // 다음 명령(다른 호스트의 CCP 쓰기)이 처리되지 않고 관찰자는 [null, 새 보유자] 순서로만 받는다.
+ // 잠금을 풀고 올리면 그 틈에 응답기가 새 보유자를 먼저 알려 [새 보유자, null] 로 뒤집힐 수 있다.
+ if (previousOwner is not null) ControlOwnerChanged?.Invoke(null);
+ }
+ }
+
/// 피처 페이지를 생성 시 옵션 값으로 되돌린다(UserSetLoad). FrameCounter 는 유지한다.
public void ResetFeatures()
{
@@ -287,27 +340,13 @@ private void InitBootstrap()
if (Opt.SupportPendingAck) cap |= GvbsAddr.GvcpCapPendingAck;
r.WriteU32(GvbsAddr.GvcpCapability, cap);
- r.WriteU32(GvbsAddr.HeartbeatTimeout, (uint)Math.Max(0, Opt.HeartbeatTimeoutMs));
r.WriteU32(GvbsAddr.TimestampTickFreqHigh, 0);
r.WriteU32(GvbsAddr.TimestampTickFreqLow, 1_000_000_000);
- r.WriteU32(GvbsAddr.TimestampControl, 0);
- r.WriteU32(GvbsAddr.TimestampLatchedHigh, 0);
- r.WriteU32(GvbsAddr.TimestampLatchedLow, 0);
r.WriteU32(GvbsAddr.DiscoveryAckDelay, 0);
- r.WriteU32(GvbsAddr.GvcpConfig, 0);
r.WriteU32(GvbsAddr.PendingTimeout, (uint)Math.Max(0, Opt.PendingAckDelayMs));
-
- r.WriteU32(GvbsAddr.Ccp, 0);
- r.WriteU32(GvbsAddr.PrimaryAppPort, 0);
- r.WriteU32(GvbsAddr.PrimaryAppIp, 0);
-
- r.WriteU32(GvbsAddr.StreamChannel(0, GvbsAddr.ScpOffset), 0);
- r.WriteU32(GvbsAddr.StreamChannel(0, GvbsAddr.ScpsOffset), (uint)Opt.DefaultPacketSize & GvbsAddr.ScpsSizeMask);
- r.WriteU32(GvbsAddr.StreamChannel(0, GvbsAddr.ScpdOffset), 0);
- r.WriteU32(GvbsAddr.StreamChannel(0, GvbsAddr.ScdaOffset), 0);
r.WriteU32(GvbsAddr.StreamChannel(0, GvbsAddr.ScspOffset), 0);
r.WriteU32(GvbsAddr.StreamChannel(0, GvbsAddr.SccOffset), SimStreamBits.SccPacketResend | SimStreamBits.SccExtendedIds);
- r.WriteU32(GvbsAddr.StreamChannel(0, GvbsAddr.SccfgOffset), Opt.ExtendedIds ? SimStreamBits.SccfgExtendedIds : 0);
+ ResetVolatileBootstrap();
// 쓰기 보호 표 — 식별·능력·읽기 전용 상태 레지스터
r.MarkReadOnly(GvbsAddr.Version, 4);
@@ -347,6 +386,31 @@ private void InitBootstrap()
r.MarkReadOnly(SimFeatureAddr.FrameCounter, 4);
}
+ ///
+ /// 켜질 때마다 정해진 값으로 돌아가는 부트스트랩 레지스터 — 하트비트 타임아웃, 타임스탬프 제어·래치, GVCP 설정, 제어권(CCP·PrimaryApp),
+ /// 스트림 채널 0 설정(SCP·SCPS·SCPD·SCDA·SCCFG). 생성과 가 같이 쓴다.
+ /// 식별·능력·영속 IP·사용자 이름과 소켓이 정하는 SCSP 는 건드리지 않는다. 보유자(_owner)는 부르는 쪽이 비운다.
+ ///
+ private void ResetVolatileBootstrap()
+ {
+ var r = Registers;
+ r.WriteU32(GvbsAddr.HeartbeatTimeout, (uint)Math.Max(0, Opt.HeartbeatTimeoutMs));
+ r.WriteU32(GvbsAddr.TimestampControl, 0);
+ r.WriteU32(GvbsAddr.TimestampLatchedHigh, 0);
+ r.WriteU32(GvbsAddr.TimestampLatchedLow, 0);
+ r.WriteU32(GvbsAddr.GvcpConfig, 0);
+
+ r.WriteU32(GvbsAddr.Ccp, 0);
+ r.WriteU32(GvbsAddr.PrimaryAppPort, 0);
+ r.WriteU32(GvbsAddr.PrimaryAppIp, 0);
+
+ r.WriteU32(GvbsAddr.StreamChannel(0, GvbsAddr.ScpOffset), 0);
+ r.WriteU32(GvbsAddr.StreamChannel(0, GvbsAddr.ScpsOffset), (uint)Opt.DefaultPacketSize & GvbsAddr.ScpsSizeMask);
+ r.WriteU32(GvbsAddr.StreamChannel(0, GvbsAddr.ScpdOffset), 0);
+ r.WriteU32(GvbsAddr.StreamChannel(0, GvbsAddr.ScdaOffset), 0);
+ r.WriteU32(GvbsAddr.StreamChannel(0, GvbsAddr.SccfgOffset), Opt.ExtendedIds ? SimStreamBits.SccfgExtendedIds : 0);
+ }
+
private static ushort SerialHash(string serial)
{
uint h = 2166136261;
diff --git a/tests/GevSharp.Tests/GenApi/Runtime/FormulaInvalidationTests.cs b/tests/GevSharp.Tests/GenApi/Runtime/FormulaInvalidationTests.cs
new file mode 100644
index 0000000..246b0df
--- /dev/null
+++ b/tests/GevSharp.Tests/GenApi/Runtime/FormulaInvalidationTests.cs
@@ -0,0 +1,166 @@
+using GevSharp.GenApi;
+using static GevSharp.Tests.GenApi.Runtime.RuntimeFixture;
+
+#pragma warning disable xUnit1051
+
+namespace GevSharp.Tests.GenApi.Runtime;
+
+///
+/// 값이 수식(SwissKnife/IntSwissKnife/Converter/IntConverter)의 pVariable 을 거쳐 레지스터에서 오는 노드의 무효화.
+/// 손으로 쓴 XML 조각만 쓴다 — 시뮬레이터 XML 은 이 모양(래치 뒤 캐시되는 레지스터를 수식으로 읽는 값)을 갖지 않는다.
+///
+public class FormulaInvalidationTests
+{
+ private const ulong HighAddr = 0x20;
+ private const ulong LowAddr = 0x24;
+
+ // 래치 명령이 두 레지스터(상위·하위 32비트)에 값을 붙잡아 두고, 값 노드는 IntSwissKnife 로 두 레지스터를 합쳐 읽는 모양.
+ // 두 레지스터는 캐시되는(WriteThrough 기본) 레지스터이고 pInvalidator 도 없다 — 래치를 실행해도 저절로는 새로 읽히지 않는다.
+ private static string LatchedTimestamp(string valueExtra = "")
+ => "LatchReg1" + IntReg("LatchReg", "0x10", access: "WO")
+ + IntReg("TsHigh", "0x20", access: "RO") + IntReg("TsLow", "0x24", access: "RO")
+ + "TsHighTsLow"
+ + "(HI << 32) | LO"
+ + $"{valueExtra}TsValueK";
+
+ private static long Ts(uint high, uint low) => ((long)high << 32) | low;
+
+ [Fact]
+ public async Task Invalidate_OnValueNodeBehindIntSwissKnife_RereadsTheLatchedRegisters()
+ {
+ var port = new MemoryPort();
+ port.U32(HighAddr, 1);
+ port.U32(LowAddr, 2);
+ var map = Bind(LatchedTimestamp(), port);
+ var value = map.GetInteger("TsValue");
+
+ Assert.Equal(Ts(1, 2), await value.GetAsync());
+
+ port.U32(HighAddr, 3); // 장치가 새 시각을 붙잡았다
+ port.U32(LowAddr, 4);
+ await map.GetCommand("Latch").ExecuteAsync();
+ Assert.Equal(Ts(1, 2), await value.GetAsync()); // 대조군: 래치만으로는 캐시가 그대로다(pInvalidator 없음)
+ Assert.Equal(1, port.ReadsAt(HighAddr));
+
+ value.Invalidate();
+ Assert.Equal(Ts(3, 4), await value.GetAsync()); // 무효화가 수식 변수 뒤의 레지스터까지 내려갔다
+ Assert.Equal(2, port.ReadsAt(HighAddr));
+ Assert.Equal(2, port.ReadsAt(LowAddr));
+ }
+
+ [Fact]
+ public async Task Invalidate_OnTheIntSwissKnifeItself_RereadsItsVariables()
+ {
+ var port = new MemoryPort();
+ port.U32(HighAddr, 1);
+ port.U32(LowAddr, 2);
+ var map = Bind(LatchedTimestamp(), port);
+ var knife = map.GetInteger("TsValueK");
+
+ Assert.Equal(Ts(1, 2), await knife.GetAsync());
+ port.U32(LowAddr, 5);
+ knife.Invalidate();
+ Assert.Equal(Ts(1, 5), await knife.GetAsync());
+ Assert.Equal(2, port.ReadsAt(LowAddr));
+ }
+
+ [Fact]
+ public async Task PInvalidatorOnValueNodeBehindIntSwissKnife_RefreshesTheFormulaInputs()
+ {
+ // pInvalidator 가 레지스터가 아니라 값 노드에 붙은 모양 — 래치를 쓰면 값 노드가 낡았다고 선언된 것이므로
+ // 그 값을 만드는 수식의 입력 레지스터까지 버려야 한다.
+ var port = new MemoryPort();
+ port.U32(HighAddr, 1);
+ port.U32(LowAddr, 2);
+ var map = Bind(LatchedTimestamp("Latch"), port);
+ var value = map.GetInteger("TsValue");
+
+ Assert.Equal(Ts(1, 2), await value.GetAsync());
+ port.U32(HighAddr, 3);
+ port.U32(LowAddr, 4);
+ await map.GetCommand("Latch").ExecuteAsync();
+
+ Assert.Equal(Ts(3, 4), await value.GetAsync());
+ Assert.Equal(2, port.ReadsAt(HighAddr));
+ Assert.Equal(2, port.ReadsAt(LowAddr));
+ }
+
+ [Fact]
+ public async Task Invalidate_OnFloatBehindSwissKnife_RereadsTheRegister()
+ {
+ var port = new MemoryPort();
+ port.U32(0x30, 46000);
+ var body = IntReg("TempReg", "0x30", access: "RO")
+ + "TempRegT / 1000"
+ + "TempK";
+ var map = Bind(body, port);
+ var temp = map.GetFloat("Temp");
+
+ Assert.Equal(46.0, await temp.GetAsync());
+ port.U32(0x30, 47500);
+ temp.Invalidate();
+ Assert.Equal(47.5, await temp.GetAsync());
+ Assert.Equal(2, port.ReadsAt(0x30));
+ }
+
+ [Fact]
+ public async Task Invalidate_OnConverter_RereadsItsPVariableRegister()
+ {
+ // Converter 는 pValue 말고도 수식 변수로 레지스터를 읽는다(여기서는 오프셋) — 둘 다 값 사슬이다.
+ var port = new MemoryPort();
+ port.U32(0x40, 100);
+ port.U32(0x44, 7);
+ var body = "OfsRegFROM - OFSTO + OFS"
+ + "RawRegIncreasing"
+ + IntReg("RawReg", "0x40") + IntReg("OfsReg", "0x44", access: "RO");
+ var map = Bind(body, port);
+ var g = map.GetFloat("G");
+
+ Assert.Equal(107.0, await g.GetAsync());
+ port.U32(0x40, 200);
+ port.U32(0x44, 9);
+ g.Invalidate();
+ Assert.Equal(209.0, await g.GetAsync());
+ Assert.Equal(2, port.ReadsAt(0x40));
+ Assert.Equal(2, port.ReadsAt(0x44));
+ }
+
+ [Fact]
+ public async Task Invalidate_OnIntConverter_RereadsItsPVariableRegister()
+ {
+ var port = new MemoryPort();
+ port.U32(0x40, 100);
+ port.U32(0x44, 7);
+ var body = "OfsRegFROM - OFSTO + OFS"
+ + "RawRegIncreasing"
+ + IntReg("RawReg", "0x40") + IntReg("OfsReg", "0x44", access: "RO");
+ var map = Bind(body, port);
+ var g = map.GetInteger("G");
+
+ Assert.Equal(107, await g.GetAsync());
+ port.U32(0x44, 9);
+ g.Invalidate();
+ Assert.Equal(109, await g.GetAsync());
+ Assert.Equal(2, port.ReadsAt(0x44));
+ }
+
+ [Fact]
+ public async Task Invalidate_OfOneFormulaInput_LeavesTheOtherInputCached()
+ {
+ // 수식에 의존하는 노드는 "낡은 입력 때문에" 낡은 것이다 — 그 입력만 버리면 되고 다른 입력은 그대로 믿는다.
+ // 무효화를 수식 변수까지 내려 보내더라도 의존으로 닿은 노드에서까지 내려가면 무관한 레지스터를 다시 읽게 된다.
+ var port = new MemoryPort();
+ port.U32(HighAddr, 1);
+ port.U32(LowAddr, 2);
+ var map = Bind(LatchedTimestamp(), port);
+ var value = map.GetInteger("TsValue");
+
+ Assert.Equal(Ts(1, 2), await value.GetAsync());
+ port.U32(HighAddr, 3);
+ map.GetInteger("TsHigh").Invalidate();
+
+ Assert.Equal(Ts(3, 2), await value.GetAsync());
+ Assert.Equal(2, port.ReadsAt(HighAddr));
+ Assert.Equal(1, port.ReadsAt(LowAddr)); // 형제 입력은 캐시에서
+ }
+}
diff --git a/tests/GevSharp.Tests/GenApi/Runtime/OtherNodeTests.cs b/tests/GevSharp.Tests/GenApi/Runtime/OtherNodeTests.cs
index f47ec6f..43d0a55 100644
--- a/tests/GevSharp.Tests/GenApi/Runtime/OtherNodeTests.cs
+++ b/tests/GevSharp.Tests/GenApi/Runtime/OtherNodeTests.cs
@@ -249,6 +249,70 @@ public async Task Command_SelfClearingDevice_IsDoneImmediately()
Assert.True(await start.IsDoneAsync());
}
+ // 완료 되읽기는 명령 자신의 접근 모드를 따른다 — 내부 값 경로는 검사 없이 포트를 부르므로, 거르지 않으면
+ // 쓰기 전용 주소나 없는 기능의 주소에 READREG 가 나가고 장치 거절이 전송 예외로 올라온다.
+
+ [Fact]
+ public async Task Command_WithPollingTime_WriteOnlyRegister_IsDoneWithoutReading()
+ {
+ var port = new MemoryPort();
+ var body = "R110"
+ + IntReg("R", "0x10", access: "WO");
+ var start = Bind(body, port).GetCommand("Start");
+
+ await start.ExecuteAsync();
+ Assert.True(await start.IsDoneAsync()); // 되읽을 길이 없다 — PollingTime 이 없을 때와 같은 뜻
+ Assert.Equal(0, port.ReadsAt(0x10));
+ }
+
+ [Fact]
+ public async Task Command_WithPollingTime_ImposedWriteOnly_IsDoneWithoutReading()
+ {
+ var port = new MemoryPort();
+ var body = "WOR110"
+ + IntReg("R", "0x10");
+ var start = Bind(body, port).GetCommand("Start");
+
+ await start.ExecuteAsync();
+ Assert.True(await start.IsDoneAsync());
+ Assert.Equal(0, port.ReadsAt(0x10));
+ }
+
+ [Fact]
+ public async Task Command_WithPollingTime_LockedWriteOnly_IsDoneWithoutReading()
+ {
+ // 잠긴 쓰기 전용 명령은 접근 모드가 NotAvailable 로 합성된다. 잠금은 쓰기만 막으므로 되읽기 판단에서는 여전히 쓰기 전용이다 —
+ // 없는 기능처럼 던지지 않고, 읽지도 않는다.
+ var port = new MemoryPort();
+ port.U32(0x10, 1);
+ var body = "WOLockedR110"
+ + "1" + IntReg("R", "0x10");
+ var map = Bind(body, port);
+ var start = map.GetCommand("Start");
+
+ Assert.Equal(AccessMode.NotAvailable, await start.GetAccessModeAsync());
+ Assert.True(await start.IsDoneAsync());
+ Assert.Equal(0, port.ReadsAt(0x10));
+ }
+
+ [Theory]
+ [InlineData("pIsImplemented", "not implemented")]
+ [InlineData("pIsAvailable", "not available")]
+ public async Task Command_WithPollingTime_NotImplementedOrNotAvailable_ThrowsWithoutReading(string guard, string reason)
+ {
+ var port = new MemoryPort();
+ port.U32(0x10, 1);
+ var body = $"<{guard}>Gate{guard}>R110"
+ + "0" + IntReg("R", "0x10");
+ var start = Bind(body, port).GetCommand("Start");
+
+ var ex = await Assert.ThrowsAsync(() => start.IsDoneAsync().AsTask());
+
+ Assert.Contains(reason, ex.Message);
+ Assert.Equal("Start", ex.NodeName);
+ Assert.Equal(0, port.ReadsAt(0x10));
+ }
+
[Fact]
public async Task Command_WithoutRegister_ExecutesLocally()
{
diff --git a/tests/GevSharp.Tests/GenApi/Runtime/SimNodeMapTests.cs b/tests/GevSharp.Tests/GenApi/Runtime/SimNodeMapTests.cs
index 1d9517a..c68132d 100644
--- a/tests/GevSharp.Tests/GenApi/Runtime/SimNodeMapTests.cs
+++ b/tests/GevSharp.Tests/GenApi/Runtime/SimNodeMapTests.cs
@@ -270,6 +270,46 @@ public async Task AcquisitionStart_LocksWidthUntilStop()
Assert.Equal(128u, s.Sim.Registers.ReadU32(SimFeatureAddr.Width));
}
+ [Fact]
+ public async Task AcquisitionStart_LocksAcquisitionModeAndImageFormatUntilStop()
+ {
+ // 실제 카메라는 획득이 도는 동안 모드와 이미지 형식(ROI 위치·반전 포함)을 잠근다 — 하류가 AcquisitionStop 을
+ // 빠뜨려 다음 연속 획득으로 못 넘어가는 결함을, 시뮬레이터에서도 같은 거절로 드러내기 위한 잠금이다.
+ await using var s = await Session.OpenAsync();
+ var mode = s.Map.GetEnumeration("AcquisitionMode");
+ var offsetX = s.Map.GetInteger("OffsetX");
+ var offsetY = s.Map.GetInteger("OffsetY");
+ var reverseX = s.Map.GetBoolean("ReverseX");
+ Assert.False(await mode.IsLockedAsync());
+
+ Assert.True(await s.Device.SetTlParamsLockedAsync(true));
+ await s.Map.GetCommand("AcquisitionStart").ExecuteAsync();
+ await WaitUntilAsync(() => s.Sim.IsAcquiring);
+
+ var ex = await Assert.ThrowsAsync(() => mode.SetAsync("SingleFrame").AsTask());
+ Assert.Contains("locked", ex.Message);
+ Assert.Equal(SimFeatureAddr.AcquisitionModeContinuous, s.Sim.Registers.ReadU32(SimFeatureAddr.AcquisitionMode));
+ Assert.Equal("Continuous", await mode.GetAsync()); // 잠김은 쓰기만 막는다
+ Assert.Equal(AccessMode.ReadOnly, await mode.GetAccessModeAsync());
+ await Assert.ThrowsAsync(() => offsetX.SetAsync(4).AsTask());
+ await Assert.ThrowsAsync(() => offsetY.SetAsync(2).AsTask());
+ await Assert.ThrowsAsync(() => reverseX.SetAsync(true).AsTask());
+ Assert.Equal(0u, s.Sim.Registers.ReadU32(SimFeatureAddr.OffsetX));
+ Assert.Equal(0u, s.Sim.Registers.ReadU32(SimFeatureAddr.ReverseX));
+
+ await s.Map.GetCommand("AcquisitionStop").ExecuteAsync();
+ await WaitUntilAsync(() => !s.Sim.IsAcquiring);
+ Assert.True(await s.Device.SetTlParamsLockedAsync(false));
+
+ Assert.False(await mode.IsLockedAsync());
+ await mode.SetAsync("SingleFrame");
+ Assert.Equal(SimFeatureAddr.AcquisitionModeSingleFrame, s.Sim.Registers.ReadU32(SimFeatureAddr.AcquisitionMode));
+ await offsetX.SetAsync(4);
+ await reverseX.SetAsync(true);
+ Assert.Equal(4u, s.Sim.Registers.ReadU32(SimFeatureAddr.OffsetX));
+ Assert.Equal(1u, s.Sim.Registers.ReadU32(SimFeatureAddr.ReverseX));
+ }
+
[Fact]
public async Task GevSCPSPacketSize_MaskedWritePreservesFlagBits()
{
@@ -302,6 +342,39 @@ public async Task TimestampLatch_CommandInvalidatesLatchedValue()
Assert.True(await latched.GetAsync() > first);
}
+ [Fact]
+ public async Task GevTimestampNodes_ResetAndLatchTheDeviceCounter()
+ {
+ // 실제 GigE 카메라의 기술이 쓰는 전송 계층 이름 — 장치 시계로 프레임을 짝짓는 하류 코드가 이 이름으로 찾는다.
+ await using var s = await Session.OpenAsync();
+ var latch = s.Map.GetCommand("GevTimestampControlLatch");
+ var reset = s.Map.GetCommand("GevTimestampControlReset");
+ var value = s.Map.GetInteger("GevTimestampValue");
+ Assert.Equal(1_000_000_000, await s.Map.GetInteger("GevTimestampTickFrequency").GetAsync());
+
+ // 카운터를 흘려 둔 뒤에 reset 한다 — 열자마자 reset 하면 reset 노드가 아무것도 안 해도(엉뚱한 CommandValue) 아래 범위를 통과한다.
+ await Task.Delay(250);
+ var h1 = Stopwatch.GetTimestamp();
+ await reset.ExecuteAsync();
+ Assert.Equal(0, await value.GetAsync()); // reset 은 래치하지 않는다
+ await Task.Delay(20);
+ await latch.ExecuteAsync();
+ var h2 = Stopwatch.GetTimestamp();
+ var first = await value.GetAsync();
+ // 10 ms .. 20 s — reset 뒤의 틱(1 GHz)이다. 굶주린 러너가 늘리는 것은 대기뿐이라 상한은 눈금만 지킨다.
+ Assert.InRange(first, 10_000_000L, 20_000_000_000L);
+ Assert.True((ulong)first <= s.Sim.TimestampTicks, "a latched value cannot be ahead of the counter that stamps frames");
+ // reset 이 카운터를 실제로 되돌렸는지: 시뮬레이터는 호스트와 같은 시계로 세므로, reset 을 보내기 직전부터 latch 가 끝날 때까지의
+ // 시간이 래치 값의 상한이다(1 µs 는 ns 환산의 끝자리 여유). reset 이 아무것도 안 하면 앞서 흘린 250 ms 와 열기 시간을 싣고 넘는다.
+ var bracketNs = (long)((h2 - h1) * (1_000_000_000.0 / Stopwatch.Frequency)) + 1_000;
+ Assert.True(first <= bracketNs, $"after GevTimestampControlReset the latched count {first} ns must fit in the {bracketNs} ns since the reset was sent");
+
+ // 두 이름 가족은 같은 레지스터를 본다.
+ Assert.Equal(first, await s.Map.GetInteger("TimestampLatchValue").GetAsync());
+ await s.Map.GetCommand("TimestampLatch").ExecuteAsync();
+ Assert.True(await value.GetAsync() > first);
+ }
+
[Fact]
public async Task BooleanAndFloatFeatures_RoundTripThroughDevice()
{
diff --git a/tests/GevSharp.Tests/Gvcp/DiscoveryBroadcastTests.cs b/tests/GevSharp.Tests/Gvcp/DiscoveryBroadcastTests.cs
index 9c4a27d..81f6125 100644
--- a/tests/GevSharp.Tests/Gvcp/DiscoveryBroadcastTests.cs
+++ b/tests/GevSharp.Tests/Gvcp/DiscoveryBroadcastTests.cs
@@ -88,8 +88,10 @@ public void BothBroadcastsDisabledAndNoUnicast_LeavesNothingToSend()
public void EveryUpIpv4InterfaceIsEnumerated_AndLoopbackIsOptIn()
{
var withLoopback = GevNet.GetIpv4Interfaces(includeLoopback: true);
- var withoutLoopback = GevNet.GetIpv4Interfaces(includeLoopback: false);
+ var withoutLoopback = GevNet.GetIpv4Interfaces(includeLoopback: false, out var enumerated);
+ // 목록을 읽었다고 답해야 한다 — 빈 탐색 결과의 까닭을 "조회 실패" 와 "해당 인터페이스 없음" 으로 가르는 근거다.
+ Assert.True(enumerated);
Assert.NotEmpty(withLoopback);
Assert.All(withLoopback, i => Assert.Equal(AddressFamily.InterNetwork, i.Address.AddressFamily));
Assert.Contains(withLoopback, i => i.IsLoopback);
diff --git a/tests/GevSharp.Tests/Gvcp/DiscoveryIsolatedTests.cs b/tests/GevSharp.Tests/Gvcp/DiscoveryIsolatedTests.cs
new file mode 100644
index 0000000..a09bcf0
--- /dev/null
+++ b/tests/GevSharp.Tests/Gvcp/DiscoveryIsolatedTests.cs
@@ -0,0 +1,146 @@
+using System.Diagnostics;
+using System.Net;
+using System.Runtime.InteropServices;
+using GevSharp.Tests.GenApi.Model;
+
+// 테스트마다 자체 타임아웃을 두므로 xunit 취소 토큰 전달 권고(xUnit1051)는 끈다.
+#pragma warning disable xUnit1051
+
+namespace GevSharp.Tests.Gvcp;
+
+///
+/// 프로세스 전역(로그 싱크·열린 핸들 수)을 보는 탐색 테스트 — 다른 테스트가 나란히 돌면 로그가 섞이고 핸들 수가 흔들리므로
+/// 다른 어떤 컬렉션과도 나란히 돌지 않는 격리 컬렉션에 둔다. 여기 있는 탐색은 전부 창을 열기 전에 끝나는 길만 탄다 —
+/// 싱크를 쥔 채 네트워크를 기다리지 않는다.
+///
+[Collection(GevLogSinkCollection.Name)]
+public class DiscoveryIsolatedTests
+{
+ ///
+ /// 이 호스트의 인터페이스 주소일 수 없는 주소(문서용 예약 대역 TEST-NET-1) — 여기에 묶으면 바인드가 곧바로 실패한다.
+ /// 설령 묶이더라도 아래 옵션은 브로드캐스트를 끄고 루프백의 닫힌 포트로만 보내므로 호스트 밖으로 아무것도 나가지 않는다.
+ ///
+ private static readonly IPAddress UnbindableAddress = IPAddress.Parse("192.0.2.1");
+
+ private static GevDiscoveryOpt UnbindableOpt(int interfaceCount) => new()
+ {
+ Interfaces = Enumerable.Repeat(UnbindableAddress, interfaceCount).ToArray(),
+ LimitedBroadcast = false,
+ DirectedBroadcast = false,
+ UnicastTargets = new[] { new IPEndPoint(IPAddress.Loopback, 9) },
+ TimeoutMs = 5000,
+ };
+
+ /// 이 프로세스가 연 핸들(리눅스는 파일 기술자) 수. 셀 방법이 없는 플랫폼이면 null.
+ private static int? OpenHandleCount()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ using var self = Process.GetCurrentProcess();
+ return self.HandleCount;
+ }
+ const string fdDir = "/proc/self/fd";
+ return Directory.Exists(fdDir) ? Directory.GetFileSystemEntries(fdDir).Length : null;
+ }
+
+ /// 탐색 한 번을 돌리며 GevDiscovery 가 남긴 Info 이상의 로그를 모은다. 다른 출처(잔류 수신 스레드 등)의 로그는 거른다.
+ private static async Task<(IReadOnlyList Result, List<(GevLogLevel Level, string Message)> Logged)> DiscoverCapturingLogAsync(GevDiscoveryOpt opt)
+ {
+ var logged = new List<(GevLogLevel Level, string Message)>();
+ var prevSink = GevLog.Sink;
+ var prevLevel = GevLog.MinLevel;
+ IReadOnlyList result;
+ try
+ {
+ GevLog.Sink = (lvl, src, msg, _) =>
+ {
+ if (src == "GevDiscovery") lock (logged) logged.Add((lvl, msg));
+ };
+ GevLog.MinLevel = GevLogLevel.Info;
+ result = await GevDiscovery.DiscoverAsync(opt);
+ }
+ finally
+ {
+ GevLog.Sink = prevSink;
+ GevLog.MinLevel = prevLevel;
+ }
+ lock (logged) return (result, logged.ToList());
+ }
+
+ [Fact]
+ public async Task AnEmptyInterfaceListIsNamedAsTheReasonForTheEmptyResult()
+ {
+ // 빈 목록은 "창 동안 아무도 답하지 않았다" 와 모양이 같다 — 창을 열지도 않은 까닭을 경고 한 줄로 밝혀야 둘이 갈린다.
+ var (result, logged) = await DiscoverCapturingLogAsync(new GevDiscoveryOpt { Interfaces = Array.Empty() });
+
+ Assert.Empty(result);
+ var warn = Assert.Single(logged);
+ Assert.Equal(GevLogLevel.Warn, warn.Level);
+ Assert.Contains("GevDiscoveryOpt.Interfaces is an empty list", warn.Message);
+ Assert.Contains("without waiting", warn.Message);
+ }
+
+ [Fact]
+ public async Task WhenNoInterfaceCouldSend_TheSummaryWarnsThatNothingWasSent()
+ {
+ // 인터페이스는 있었지만 어느 것에서도 소켓을 못 열었다 — 인터페이스마다 까닭을 경고하고, 마지막 요약도 "탐색을 마쳤다"
+ // 가 아니라 "아무것도 보내지 못했다" 는 경고여야 한다. 요약이 Info 로 남으면 빈 목록이 "아무도 답하지 않았다" 로 읽힌다.
+ var (result, logged) = await DiscoverCapturingLogAsync(UnbindableOpt(2));
+
+ Assert.Empty(result);
+ Assert.Equal(2, logged.Count(e => e.Level == GevLogLevel.Warn && e.Message.Contains("cannot bind a discovery socket")));
+ var summary = Assert.Single(logged, e => e.Message.Contains("no DISCOVERY_CMD was sent"));
+ Assert.Equal(GevLogLevel.Warn, summary.Level);
+ Assert.Contains("2 interface(s)", summary.Message);
+ Assert.DoesNotContain(logged, e => e.Message.StartsWith("discovery finished", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task ASocketWhoseBindFailsIsClosedAtOnce_NotLeftToTheFinalizer()
+ {
+ // 인터페이스 하나마다 소켓을 하나 만들고, 바인드가 실패하면 그 인터페이스를 건너뛴다. 그 소켓을 닫지 않으면
+ // 핸들은 GC 종료자가 돌 때까지 남는다 — 탐색을 부를 때마다 실패한 인터페이스 수만큼 쌓인다.
+ // 같은 주소를 여러 번 주면 한 번의 탐색(인터페이스 열거 한 번)으로 실패를 여러 번 만든다.
+ const int interfaces = 500;
+ var before = OpenHandleCount();
+ if (before is null) Assert.Skip("this platform offers no way to count open handles");
+
+ // 재는 동안 GC 를 막는다 — 도중에 GC 가 돌면 종료자가 버려진 소켓을 닫아 새는 판도 수가 작게 나온다(실측: 막지 않으면
+ // 새는 판이 +143 으로 문턱 아래에 들어왔다). 예산을 넘겨 할당하면 런타임이 스스로 구역을 끝내므로 끝낼 때는 아직 구역
+ // 안인지 보고 끝낸다. 예산이 그 런타임의 한도를 넘으면(32비트 등) 막지 못한 채 잰다 — 그때 이 테스트는 새는 판을 놓칠 수
+ // 있을 뿐 닫는 판을 떨어뜨리지는 않는다.
+ bool noGc;
+ try
+ {
+ noGc = GC.TryStartNoGCRegion(32L * 1024 * 1024);
+ }
+ catch (Exception ex) when (ex is ArgumentOutOfRangeException or InvalidOperationException)
+ {
+ noGc = false;
+ }
+ IReadOnlyList result;
+ int after;
+ try
+ {
+ result = await GevDiscovery.DiscoverAsync(UnbindableOpt(interfaces));
+ after = OpenHandleCount()!.Value;
+ }
+ finally
+ {
+ if (noGc && System.Runtime.GCSettings.LatencyMode == System.Runtime.GCLatencyMode.NoGCRegion)
+ GC.EndNoGCRegion();
+ }
+
+ // 대조군: 전체 GC 로 종료자를 돌린 뒤의 수. 새는 판에서는 여기서 수가 도로 내려가 "차이가 버려진 소켓이었다" 를 보인다.
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ GC.Collect();
+ var afterGc = OpenHandleCount()!.Value;
+
+ Assert.Empty(result);
+ // 문턱을 실패 수의 절반으로 둔다 — 격리 컬렉션이어도 런타임 자신이 스레드·이벤트 핸들을 조금씩 여닫는 흔들림은 남는다.
+ // 실측(Windows, net8.0): 소켓을 닫지 않던 판 +533(전체 GC 뒤 +35), 닫는 판 +33(전체 GC 뒤 그대로).
+ Assert.True(after - before.Value < interfaces / 2,
+ $"{after - before.Value} handle(s) stayed open after {interfaces} failed binds (before {before}, after {after}, after a full GC {afterGc}, GC held off: {noGc})");
+ }
+}
diff --git a/tests/GevSharp.Tests/Gvcp/GevDeviceLogTests.cs b/tests/GevSharp.Tests/Gvcp/GevDeviceLogTests.cs
index f2b466b..f00730c 100644
--- a/tests/GevSharp.Tests/Gvcp/GevDeviceLogTests.cs
+++ b/tests/GevSharp.Tests/Gvcp/GevDeviceLogTests.cs
@@ -39,4 +39,30 @@ public void ATooTightHeartbeatWarnsAndFallsBackToOneResponseWindow()
Assert.Equal("GevDevice", entry.Source);
Assert.Contains("no PENDING_ACK budget", entry.Message);
}
+
+ [Fact]
+ public void AHugeResponseWindowDoesNotWrapThePendingAckBudget()
+ {
+ // 응답 창이 int.MaxValue / 2 를 넘으면 2 × 응답 창을 int 로 셈할 때 음수로 감긴다. 그러면 빼기가 더하기가 되어
+ // 여유가 없는 설정이 오히려 수십억 ms 의 상한을 얻고, 경고도 나지 않는다.
+ var logged = new List<(GevLogLevel Level, string Source, string Message)>();
+ var prevSink = GevLog.Sink;
+ var prevLevel = GevLog.MinLevel;
+ int cap;
+ try
+ {
+ GevLog.Sink = (lvl, src, msg, _) => { lock (logged) logged.Add((lvl, src, msg)); };
+ GevLog.MinLevel = GevLogLevel.Warn;
+ cap = GevDevice.AutoPendingAckWaitMs(3000, 1000, 1_100_000_000);
+ }
+ finally
+ {
+ GevLog.Sink = prevSink;
+ GevLog.MinLevel = prevLevel;
+ }
+
+ Assert.Equal(1_100_000_000, cap); // 여유가 없으니 응답 창 하나로 떨어진다
+ var entry = Assert.Single(logged);
+ Assert.Contains("no PENDING_ACK budget", entry.Message);
+ }
}
diff --git a/tests/GevSharp.Tests/Gvcp/GevDeviceTests.cs b/tests/GevSharp.Tests/Gvcp/GevDeviceTests.cs
index f057a4a..0e946f5 100644
--- a/tests/GevSharp.Tests/Gvcp/GevDeviceTests.cs
+++ b/tests/GevSharp.Tests/Gvcp/GevDeviceTests.cs
@@ -213,6 +213,46 @@ public async Task CcpReleasedElsewhereRaisesControlLost()
Assert.Contains("another application", later.Message);
}
+ [Fact]
+ public async Task AHeartbeatTimeoutBeyondIntRangeIsSaturatedNotNegative()
+ {
+ // GVBS 0x0938 은 부호 없는 32비트다. int 로 그냥 옮기면 0xFFFFFFFF 가 -1(= Timeout.Infinite)이 되어,
+ // "장치가 적용한 타임아웃" 이라는 공개 값을 대기 시간으로 쓰는 호출자가 영영 기다린다.
+ using var r = new GvcpTestResponder();
+ r.WriteU32(GvbsAddr.HeartbeatTimeout, 0xFFFF_FFFF);
+ await using var dev = await GevDevice.OpenAsync(r.EndPoint, FastOpt(o => o.AccessMode = GevAccessMode.ReadOnly));
+
+ Assert.Equal(int.MaxValue, dev.DeviceHeartbeatTimeoutMs);
+ }
+
+ [Fact]
+ public async Task AHeartbeatTimeoutBeyondIntRangeKeepsTheHeartbeatOnTheRequestedTimeout()
+ {
+ // 제어 세션에서 장치가 하트비트 타임아웃 쓰기를 받아 놓고 0xFFFFFFFF 를 고집한다. 공개 값은 포화될 뿐 음수가 아니고,
+ // 주기와 PENDING_ACK 상한은 그 값이 아니라 요청한 타임아웃(3000)으로 끌어낸다 — 포화된 값으로 끌어내면 하트비트가
+ // 8 일에 한 번이 된다. 제어권 상실 문구도 음수가 아니라 그 값을 싣는다.
+ using var r = new GvcpTestResponder();
+ r.WriteU32(GvbsAddr.HeartbeatTimeout, 0xFFFF_FFFF);
+ r.WriteIgnoredAddr = GvbsAddr.HeartbeatTimeout;
+ await using var dev = await GevDevice.OpenAsync(r.EndPoint, FastOpt(o => o.HeartbeatPeriodMs = null));
+
+ Assert.True(HasWriteOf(r.Requests, GvbsAddr.HeartbeatTimeout, 3000), "the requested heartbeat timeout was not written");
+ Assert.Equal(0xFFFF_FFFFu, r.ReadU32(GvbsAddr.HeartbeatTimeout));
+ Assert.Equal(int.MaxValue, dev.DeviceHeartbeatTimeoutMs);
+ Assert.Equal(1000, dev.HeartbeatPeriodMs); // 3000 / 3
+ Assert.Equal(1400, dev.Gvcp.Opt.MaxPendingAckWaitMs); // 3000 - 1000 - 2*300
+
+ var lost = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ dev.ControlLost += (d, ex) => lost.TrySetResult(ex);
+ r.WriteU32(GvbsAddr.Ccp, 0);
+ var done = await Task.WhenAny(lost.Task, Task.Delay(10_000));
+ Assert.Same(lost.Task, done);
+
+ var lostEx = Assert.IsType(await lost.Task);
+ Assert.Contains("another application", lostEx.Message);
+ Assert.True(lostEx.Message.Contains($"device timeout {int.MaxValue} ms"), lostEx.Message);
+ }
+
[Fact]
public async Task ControlLostAfterAStallSaysSo_AndEveryLaterCallRepeatsTheReason()
{
@@ -325,6 +365,46 @@ public async Task AStalledPendingAckReleasesTheChannelWithinTheHeartbeatWindow()
Assert.True(sw.ElapsedMilliseconds < 2000, $"the stalled request held the GVCP channel for {sw.ElapsedMilliseconds} ms");
}
+ [Fact]
+ public async Task MaxPendingAckWaitZeroMeansNoExtensionAndNoResend()
+ {
+ // 0 은 "상한 없음" 도 "PENDING_ACK 무시" 도 아니다 — PENDING_ACK 이 늘려 줄 수 있는 시간이 0 이라는 뜻이다.
+ // 그런 명령은 응답 창 하나 안에 끝나야 하고, 장치가 "받아서 실행 중" 이라고 알렸으므로 재시도 설정과 무관하게
+ // 다시 보내지 않고 시한 초과로 끝난다. 하트비트는 시험 동안 돌지 않게 멀리 둔다(대조군의 침묵이 하트비트를 깨지 않게).
+ using var r = new GvcpTestResponder();
+ await using var dev = await GevDevice.OpenAsync(r.EndPoint, FastOpt(o =>
+ {
+ o.GvcpTimeoutMs = 500; o.GvcpRetries = 3; o.MaxPendingAckWaitMs = 0;
+ o.HeartbeatTimeoutMs = 120_000; o.HeartbeatPeriodMs = 60_000;
+ }));
+ Assert.Equal(0, dev.Gvcp.Opt.MaxPendingAckWaitMs); // 명시한 0 은 하트비트에 맞춘 자동 계산으로 바뀌지 않는다
+
+ // PENDING_ACK 뒤에 곧바로 온 본 응답은 응답 창 안이라 그대로 받는다 — 0 이 PENDING_ACK 를 받은 명령을 전부 실패시키는 것은 아니다.
+ r.WriteU32(0x4008, 0x1234_5678);
+ r.PendingAckAddr = 0x4008;
+ r.PendingAckMs = 5000;
+ Assert.Equal(0x1234_5678u, await dev.ReadRegAsync(0x4008));
+ r.PendingAckMs = 0;
+ r.PendingAckAddr = null;
+ Assert.Equal(1, dev.Gvcp.PendingAckCount);
+
+ r.PendingAckStallAddr = 0x4000;
+ var sw = Stopwatch.StartNew();
+ await Assert.ThrowsAsync(() => dev.ReadRegAsync(0x4000));
+ sw.Stop();
+ r.PendingAckStallAddr = null;
+ // 상한은 "0 을 상한 없음으로 읽는" 회귀만 겨냥한다 — 그러면 장치가 예고한 60 s 를 다 기다린다. 응답 창(200 ms)을 재지는 않는다.
+ Assert.True(sw.ElapsedMilliseconds < 8000, $"a PENDING_ACK'd request held the channel for {sw.ElapsedMilliseconds} ms with MaxPendingAckWaitMs = 0");
+ Assert.Equal(1, r.CountOfReg(GvcpConst.ReadRegCmd, 0x4000));
+ Assert.Equal(2, dev.Gvcp.PendingAckCount);
+
+ // 대조: 같은 설정에서 PENDING_ACK 없이 무응답인 명령은 1 + 3 번 보낸다 — 위의 "한 번" 은 재시도가 꺼져서가 아니다.
+ r.IsSilent = true;
+ await Assert.ThrowsAsync(() => dev.ReadRegAsync(0x4004));
+ r.IsSilent = false;
+ Assert.Equal(4, r.CountOfReg(GvcpConst.ReadRegCmd, 0x4004));
+ }
+
[Fact]
public async Task TheHeartbeatKeepsReachingTheDeviceWhileAStalledRequestHoldsTheChannel()
{
@@ -791,4 +871,21 @@ public async Task WideGenApiAddressUsesItsLow32BitsBecauseGvcpCarriesNoMore()
Assert.Single(wide);
Assert.Contains("0x00002000", wide[0]);
}
+
+ [Fact]
+ public async Task AWideAddressWhoseNarrowedEndLeavesThe32BitSpaceIsRejectedBeforeSending()
+ {
+ // 좁히기는 상위 비트만 버린다. 좁힌 주소에서 끝이 32비트 공간을 넘으면 그건 장식이 아니라 잘못된 접근이라
+ // (벤더가 0xFFFFFFFF 를 "없음" 표식으로 쓰기도 한다) 넓은 주소여도 좁은 주소와 똑같이 보내기 전에 거절한다.
+ using var r = new GvcpTestResponder();
+ await using var dev = await GevDevice.OpenAsync(r.EndPoint, FastOpt(o => o.HeartbeatPeriodMs = 60_000));
+ IGevPort port = dev;
+ var before = r.Requests.Count;
+
+ await Assert.ThrowsAsync(() => port.ReadAsync(0xFFFF_FFFF_FFFF_FFFEUL, new byte[4]).AsTask());
+ await Assert.ThrowsAsync(() => port.WriteAsync(0x1_FFFF_FFFFUL, new byte[4]).AsTask());
+ await Assert.ThrowsAsync(() => port.ReadAsync(0x1_FFFF_FF00UL, new byte[1024]).AsTask());
+
+ Assert.Empty(r.Requests.Skip(before));
+ }
}
diff --git a/tests/GevSharp.Tests/Gvcp/GevDiscoveryTests.cs b/tests/GevSharp.Tests/Gvcp/GevDiscoveryTests.cs
index e9de66e..d030f49 100644
--- a/tests/GevSharp.Tests/Gvcp/GevDiscoveryTests.cs
+++ b/tests/GevSharp.Tests/Gvcp/GevDiscoveryTests.cs
@@ -63,6 +63,23 @@ public async Task ProbeReturnsNullWhenNothingAnswers()
Assert.Single(r.Requests);
}
+ [Fact]
+ public async Task ProbeReturnsNullWhenTheDeviceAnswersWithAnErrorStatus()
+ {
+ // 문서가 밝힌 null 의 둘째 까닭 — 장치는 거기 있고 답도 했지만 오류 status 다. 예외로 새지 않고 null 이다
+ // (브로드캐스트 탐색도 같은 응답을 목록에 넣지 않는다). 예산을 크게 두어, null 이 예산을 다 쓴 무응답이 아니라
+ // 온 응답에서 나왔다는 것을 시간으로 가른다 — 굶주린 스케줄러의 고정 비용은 이 예산에 한참 못 미친다.
+ using var r = new GvcpTestResponder();
+ r.DiscoveryErrorStatus = GvcpConst.StatusBusy;
+ const int budgetMs = 10_000;
+ var sw = Stopwatch.StartNew();
+
+ Assert.Null(await GevDiscovery.ProbeAsync(r.EndPoint, budgetMs, default));
+
+ Assert.True(sw.ElapsedMilliseconds < budgetMs, $"probe took {sw.ElapsedMilliseconds} ms; the error-status reply should have ended it before the {budgetMs} ms budget");
+ Assert.Equal(GvcpConst.DiscoveryCmd, Assert.Single(r.Requests).Command);
+ }
+
[Fact]
public async Task ProbeSkipsTruncatedDiscoveryAck()
{
@@ -270,6 +287,38 @@ public async Task DiscoverOnLoopbackBroadcastCompletesWithinTheWindow()
Assert.True(sw.ElapsedMilliseconds < 10_000, $"discovery took {sw.ElapsedMilliseconds} ms for a 150 ms window");
}
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public async Task DiscoverRejectsARepeatBelowOne(int repeat)
+ {
+ // TimeoutMs 와 같은 규칙이다 — 보낼 횟수가 1 미만인 설정 오류를 1 로 바꿔 조용히 넘기지 않는다.
+ // 인터페이스를 비워 두어, 검사가 없으면 창을 열지 않고 빈 목록으로 곧바로 돌아온다(네트워크를 타지 않는다).
+ var ex = await Assert.ThrowsAsync(
+ () => GevDiscovery.DiscoverAsync(new GevDiscoveryOpt { Interfaces = Array.Empty(), Repeat = repeat }));
+ Assert.Contains("Repeat", ex.Message);
+ }
+
+ [Fact]
+ public async Task RepeatNeverStretchesTheWindow()
+ {
+ // 창보다 훨씬 많은 반복을 요구해도 전송은 창 안에서만 하고 창이 끝나면 돌아온다. 간격은 1 ms 아래로 내려가지 않으므로
+ // 한 대상에 보내는 횟수는 창 길이(ms)를 넘을 수 없다. 창을 넘겨 계속 보내는 회귀는 가드 토큰이 끊어 취소로 드러난다
+ // (가드가 없으면 그 회귀는 사실상 끝나지 않는다).
+ using var r = new GvcpTestResponder();
+ const int windowMs = 100;
+ using var guard = new CancellationTokenSource(15_000);
+ var sw = Stopwatch.StartNew();
+
+ await GevDiscovery.DiscoverAsync(LoopbackOpt(windowMs, 1_000_000, r.EndPoint), guard.Token);
+
+ // 상한은 창을 재려는 것이 아니라(과부하에서는 소켓·스레드 비용이 얹힌다) 반복이 창을 늘리는 회귀를 겨냥한다.
+ Assert.True(sw.ElapsedMilliseconds < 10_000, $"discovery took {sw.ElapsedMilliseconds} ms for a {windowMs} ms window");
+ await GvcpChannelTests.WaitUntilAsync(() => r.CountOf(GvcpConst.DiscoveryCmd) >= 1, timeoutMs: 10_000, what: "a DISCOVERY_CMD was logged");
+ await Task.Delay(50); // 응답기의 기록이 따라잡을 틈을 준다
+ Assert.InRange(r.CountOf(GvcpConst.DiscoveryCmd), 1, windowMs);
+ }
+
[Fact]
public async Task DiscoverHonoursCancellation()
{
@@ -286,7 +335,8 @@ public async Task ForceIpValidatesInputsAndNeedsAnInterface()
var gw = IPAddress.Parse("192.168.1.1");
await Assert.ThrowsAsync(() => GevDiscovery.ForceIpAsync(null!, ip, mask, gw));
- await Assert.ThrowsAsync(() => GevDiscovery.ForceIpAsync(mac, ip, mask, gw, new GevDiscoveryOpt { Interfaces = Array.Empty() }));
+ var noIface = await Assert.ThrowsAsync(() => GevDiscovery.ForceIpAsync(mac, ip, mask, gw, new GevDiscoveryOpt { Interfaces = Array.Empty() }));
+ Assert.Contains("GevDiscoveryOpt.Interfaces is an empty list", noIface.Message); // 탐색과 같은 까닭을 밝힌다
await Assert.ThrowsAsync(() => GevDiscovery.ForceIpAsync(mac, IPAddress.IPv6Loopback, mask, gw, new GevDiscoveryOpt { Interfaces = new[] { IPAddress.Loopback } }));
// 루프백 인터페이스로는 브로드캐스트가 막힐 수 있다 — 보내졌거나 "보낼 길이 없다"로 끝나야 하고, 어느 쪽이든 멈추지 않는다.
diff --git a/tests/GevSharp.Tests/Gvcp/GvcpChannelTests.cs b/tests/GevSharp.Tests/Gvcp/GvcpChannelTests.cs
index e5c2449..6e6535b 100644
--- a/tests/GevSharp.Tests/Gvcp/GvcpChannelTests.cs
+++ b/tests/GevSharp.Tests/Gvcp/GvcpChannelTests.cs
@@ -204,6 +204,23 @@ public async Task SilentDeviceTimesOutAfterFirstSendPlusRetries()
Assert.Contains("READREG", ex.Message);
Assert.True(sw.ElapsedMilliseconds >= 100, $"gave up after only {sw.ElapsedMilliseconds} ms");
Assert.Equal(3, r.CountOf(GvcpConst.ReadRegCmd));
+ // 응답이 아예 없던 시한 초과에는 "장치가 답했다" 표식이 없다 — 이것이 장치 상실로 읽히는 쪽이다.
+ Assert.False(ex.Data.Contains(GvcpChannel.PendingAckExpiredKey));
+ }
+
+ [Fact]
+ public async Task RetriesAtIntMaxValueStillSendsTheRequest()
+ {
+ // "끝없이 재시도" 로 흔히 고르는 값이다. 총 시도 횟수(1 + Retries)를 int 로 셈하면 음수로 감겨 루프가 한 번도
+ // 돌지 않고, 장치에 아무것도 보내지 않은 채 "-2147483648 attempt(s)" 시한 초과로 끝난다.
+ using var r = new GvcpTestResponder();
+ using var ch = Open(r, timeoutMs: 300, retries: int.MaxValue);
+ r.WriteU32(0x1000, 0x5A5A5A5A);
+
+ var ack = await ch.RequestAsync(GvcpCmd.ReadReg(0x1000));
+
+ Assert.Equal(0x5A5A5A5Au, ack.GetRegValue(0));
+ Assert.Equal(1, r.CountOf(GvcpConst.ReadRegCmd));
}
[Fact]
@@ -299,12 +316,14 @@ public async Task PendingAckWaitIsCappedByMaxPendingAckWaitMs()
r.PendingAckMs = 20_000;
r.PendingAckDelayMs = 3000;
- await Assert.ThrowsAsync(() => ch.RequestAsync(GvcpCmd.ReadReg(0)));
+ var ex = await Assert.ThrowsAsync(() => ch.RequestAsync(GvcpCmd.ReadReg(0)));
// 시간은 재지 않는다. 상한을 잊는 회귀는 어느 쪽으로 가든 시계 없이 걸리기 때문이다 —
// 예고된 20 s 를 기다리든 600 ms 뒤의 진짜 ACK 를 받아들이든 결과는 "성공" 이라 위의 ThrowsAsync 가 먼저 깨진다.
// (진짜 ACK 가 반드시 오므로 20 s 를 실제로 기다리는 일 자체가 없다 — 시간 상한을 두어도 발동할 수 없었다.)
Assert.Equal(1, ch.PendingAckCount);
+ // 장치는 답했다 — 형은 무응답 시한 초과와 같아도 표식으로 갈린다(카메라 XML 적재가 이것을 장치 상실로 읽지 않는다).
+ Assert.Equal(true, ex.Data[GvcpChannel.PendingAckExpiredKey]);
}
// ---------------------------------------------------------------- fire-and-forget
diff --git a/tests/GevSharp.Tests/Gvcp/GvcpTestResponder.cs b/tests/GevSharp.Tests/Gvcp/GvcpTestResponder.cs
index d141575..381ad49 100644
--- a/tests/GevSharp.Tests/Gvcp/GvcpTestResponder.cs
+++ b/tests/GevSharp.Tests/Gvcp/GvcpTestResponder.cs
@@ -10,7 +10,7 @@ namespace GevSharp.Tests.Gvcp;
/// 루프백 최소 응답기 — 64 KiB 메모리 이미지로 DISCOVERY/READREG/WRITEREG/READMEM/WRITEMEM 에 답한다.
/// 패킷은 라이브러리의 작성기를 쓰지 않고 손으로 조립한다(대칭 오류 상쇄 방지).
/// 시나리오 노브: 지연, 틀린 req_id 선행, PENDING_ACK 선행(전체 또는 한 주소만), N 회 드롭, 침묵, 오류 상태, CCP 점유, 잘린 DISCOVERY_ACK,
-/// 잘못된 ack command, 잘린 응답, READMEM_ACK 길이 어긋남, PENDING_ACK 만 보내고 멈추는 주소.
+/// 잘못된 ack command, 잘린 응답, READMEM_ACK 길이 어긋남, PENDING_ACK 만 보내고 멈추는 주소, 쓰기를 받고도 값을 바꾸지 않는 주소.
/// 받은 요청은 도착 시각( 기준)과 함께 기록한다 — 요청 사이의 간격을 재는 시험이 쓴다.
///
internal sealed class GvcpTestResponder : IDisposable
@@ -50,12 +50,14 @@ public GvcpTestResponder()
private long _pendingAckAddr = -1;
private volatile bool _isCcpHeldByOther;
private volatile int _truncateDiscoveryTo;
+ private volatile int _discoveryErrorStatus;
private long _errorAddr = -1;
private volatile int _errorStatus = GvcpConst.StatusWriteProtect;
private volatile bool _isAckEmptyForWrites;
private volatile int _readMemLengthDelta;
private long _pendingAckStallAddr = -1;
private volatile int _pendingAckStallMs = 60_000;
+ private long _writeIgnoredAddr = -1;
/// 모든 응답을 이만큼 늦춘다.
public int ReplyDelayMs { get => _replyDelayMs; set => _replyDelayMs = value; }
@@ -75,6 +77,8 @@ public uint? PendingAckAddr
public bool IsCcpHeldByOther { get => _isCcpHeldByOther; set => _isCcpHeldByOther = value; }
/// 0 보다 크면 DISCOVERY_ACK 페이로드를 이 길이로 자른다.
public int TruncateDiscoveryTo { get => _truncateDiscoveryTo; set => _truncateDiscoveryTo = value; }
+ /// 0 이 아니면 DISCOVERY_CMD 에 페이로드 없이 이 오류 status 로 답한다 — 거기 있지만 탐색을 거절하는 장치.
+ public ushort DiscoveryErrorStatus { get => (ushort)_discoveryErrorStatus; set => _discoveryErrorStatus = value; }
/// 이 주소를 건드리는 요청에 로 답한다. null = 없음.
public uint? ErrorAddr
{
@@ -94,6 +98,12 @@ public uint? PendingAckStallAddr
}
/// 의 PENDING_ACK 가 예고하는 완료 시간.
public int PendingAckStallMs { get => _pendingAckStallMs; set => _pendingAckStallMs = value; }
+ /// 이 주소에 대한 WRITEREG 는 성공으로 답하되 값을 저장하지 않는다 — 쓰기를 받아 놓고 자기 값을 고집하는 장치 흉내. null = 없음.
+ public uint? WriteIgnoredAddr
+ {
+ get { var v = Interlocked.Read(ref _writeIgnoredAddr); return v < 0 ? null : (uint)v; }
+ set => Interlocked.Exchange(ref _writeIgnoredAddr, value.HasValue ? value.Value : -1L);
+ }
public void DropNext(int count) => Interlocked.Exchange(ref _dropNext, count);
public void WrongReqIdNext(int count) => Interlocked.Exchange(ref _wrongReqIdNext, count);
@@ -262,6 +272,8 @@ private int BuildReply(ushort command, ushort reqId, byte[] payload, byte[] repl
{
case GvcpConst.DiscoveryCmd:
{
+ if (DiscoveryErrorStatus != 0)
+ return Error(reply, GvcpConst.DiscoveryAck, reqId, DiscoveryErrorStatus);
var len = TruncateDiscoveryTo > 0 ? TruncateDiscoveryTo : GvbsAddr.DiscoveryDataLen;
Header(reply, GvcpConst.StatusSuccess, GvcpConst.DiscoveryAck, (ushort)len, reqId);
Memory.AsSpan(0, len).CopyTo(reply.AsSpan(8));
@@ -295,6 +307,8 @@ private int BuildReply(ushort command, ushort reqId, byte[] payload, byte[] repl
return IndexAck(reply, GvcpConst.WriteRegAck, reqId, (ushort)i, GvcpConst.StatusAccessDenied);
if (addr + 4 > MemorySize)
return IndexAck(reply, GvcpConst.WriteRegAck, reqId, (ushort)i, GvcpConst.StatusInvalidAddress);
+ if (WriteIgnoredAddr == addr)
+ continue;
payload.AsSpan(i * 8 + 4, 4).CopyTo(Memory.AsSpan((int)addr));
}
if (IsAckEmptyForWrites)
diff --git a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs
index 14f2ad4..be8956d 100644
--- a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs
+++ b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs
@@ -2,6 +2,7 @@
using System.Runtime.InteropServices;
using GevSharp.Gvcp;
using GevSharp.Gvsp;
+using GevSharp.Tests.GenApi.Model;
namespace GevSharp.Tests.Gvsp;
@@ -127,6 +128,11 @@ public async Task CompleteFramesAreDeliveredInOrder(bool extendedIds)
// 다섯 프레임을 받기 전에 다 보내므로 풀은 그보다 커야 한다(작으면 다섯째가 NoBuffer 로 버려진다 — 그건 다른 테스트가 본다).
var opt = StreamRig.DefaultOpt();
opt.BufferCount = 8;
+ // 침묵 규칙(재요청 간격만큼 조용하면 아직 안 온 꼬리도 구멍으로 친다)과 보존 시간은 여기서 보는 것이 아니다. 기본값 20 ms 로는
+ // 러너가 송신 쪽을 프레임 도중 그만큼만 멈춰도 짐작한 꼬리를 물어 ResendRequests 가 0 이 아니게 된다(프레임마다 30 ms 멈추는 주입으로 재현).
+ // 프레임은 마지막 페이로드에서 닫히므로 문턱을 넉넉히 둬도 이 시험은 느려지지 않는다.
+ opt.PacketTimeoutMs = 2000;
+ opt.FrameRetentionMs = 5000;
await using var rig = new StreamRig(opt);
rig.Sender.ExtendedIds = extendedIds;
await rig.StartAsync();
@@ -156,6 +162,9 @@ public async Task CompleteFramesAreDeliveredInOrder(bool extendedIds)
Assert.True(frame.Data.Span.SequenceEqual(sent[i].Data));
}
+ // 프레임은 마지막 페이로드에서 닫혀 큐에 들므로, 다섯째를 받은 순간 그 블록의 트레일러는 아직 소켓에 있을 수 있다.
+ // 계수기는 수신기가 보낸 패킷을 다 센 뒤에 본다 — 그 전에 찍으면 수신 스레드가 잠깐 밀린 것만으로 25 대 24 로 깨진다.
+ await rig.WaitUntilAsync(() => rig.Stream.Stats.PacketsReceived >= rig.Sender.PacketsSent);
var snap = rig.Stream.Stats.Snapshot();
Assert.Equal(5, snap.FramesCompleted);
Assert.Equal(5, snap.FramesDelivered);
@@ -426,6 +435,87 @@ public async Task StopUnblocksPendingReceive()
await Assert.ThrowsAsync(() => rig.Stream.StartAsync(Ct));
}
+ [Fact]
+ public async Task StopCancelledMidwayStillTurnsTheDeviceTransmissionOff()
+ {
+ // 정지 도중 취소가 와도 장치 전송 끄기(SCP = 0, SCDA = 0)는 끝까지 가야 한다. 건너뛰면 장치는 닫힌 포트를 향해
+ // 계속 쏘는데 정지는 성공으로 돌아와, 호출자는 그 사실을 알 길이 없다.
+ await using var rig = new StreamRig();
+ await rig.StartAsync();
+
+ var scp = GvbsAddr.StreamChannel(0, GvbsAddr.ScpOffset);
+ var scda = GvbsAddr.StreamChannel(0, GvbsAddr.ScdaOffset);
+ using var cts = new CancellationTokenSource();
+ rig.Regs.OnWrite = (addr, value) =>
+ {
+ if (addr == scp && value == 0) cts.Cancel(); // SCP = 0 이 나가는 바로 그때 취소가 도착한다
+ };
+
+ await rig.Stream.StopAsync(cts.Token);
+
+ Assert.True(cts.IsCancellationRequested);
+ Assert.Contains((scda, 0u), rig.Regs.Writes);
+ Assert.False(rig.Stream.IsStarted);
+ await Assert.ThrowsAsync(async () => await rig.Stream.ReceiveAsync(Ct));
+ }
+
+ [Fact]
+ public async Task StopWithAPreCancelledTokenStillStopsEverything()
+ {
+ // 셧다운 경로는 시한이 이미 지난 토큰으로 정지를 부르기 쉽다. 그래도 정지는 끝까지 한다 — 장치 전송 끄기,
+ // 소켓 닫기, 큐 비우기, 정지 상태. 아무것도 안 하고 취소만 던지면 스트림은 그대로 돌고 큐의 버퍼도 돌아오지 않는다.
+ var opt = StreamRig.DefaultOpt();
+ await using var rig = new StreamRig(opt);
+ await rig.StartAsync();
+
+ rig.Sender.SendFrame(1UL, 64, 48, Mono8);
+ await rig.WaitUntilAsync(() => rig.Stream.QueuedFrames == 1);
+
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+ await rig.Stream.StopAsync(cts.Token);
+
+ var writes = rig.Regs.Writes;
+ Assert.Contains((GvbsAddr.StreamChannel(0, GvbsAddr.ScpOffset), 0u), writes);
+ Assert.Contains((GvbsAddr.StreamChannel(0, GvbsAddr.ScdaOffset), 0u), writes);
+ Assert.False(rig.Stream.IsStarted);
+ Assert.Equal(0, rig.Stream.QueuedFrames);
+ Assert.Equal(opt.BufferCount, rig.Stream.PoolFreeBuffers);
+ await Assert.ThrowsAsync(async () => await rig.Stream.ReceiveAsync(Ct));
+ }
+
+ [Fact]
+ public async Task ReceiverThreadDyingOnItsOwnClearsIsStarted()
+ {
+ // 소켓이 죽어 수신 스레드가 스스로 끝나면 받기는 "닫힘" 으로 끝난다. 그때 IsStarted 가 계속 참이면 그 값으로
+ // "다시 열기" 와 "이미 멈춤" 을 가르는 쪽이 속는다 — 상태가 사실을 말해야 한다.
+ var opt = StreamRig.DefaultOpt();
+ await using var rig = new StreamRig(opt);
+ await rig.StartAsync();
+
+ rig.Sender.SendFrame(1UL, 64, 48, Mono8);
+ await rig.WaitUntilAsync(() => rig.Stream.QueuedFrames == 1);
+
+ rig.Stream.KillSocketForTest();
+
+ // 이미 큐에 든 장은 그대로 받아 갈 수 있고, 그 다음에 닫힘이 나온다.
+ using (var queued = await rig.ReceiveAsync()) Assert.Equal(1UL, queued.FrameId);
+ var closed = await Assert.ThrowsAsync(() => rig.Stream.ReceiveAsync(Ct).AsTask().WaitAsync(TimeSpan.FromSeconds(10), Ct));
+ Assert.False(rig.Stream.IsStarted);
+ // 사유가 "실패(Success)" 같은 모순이 아니어야 한다 — 닫힌 소켓이 대기에서 예외로 오든(Interrupted 등) 다음 수신의 ObjectDisposedException 으로 오든.
+ Assert.DoesNotContain("(Success)", closed.Message);
+
+ // 스스로 끝난 스트림은 멈춘 것이 아니라 정리를 기다리는 것이다 — 다시 시작할 수는 없고,
+ // 정지를 불러야 장치 전송이 꺼지고 버퍼가 돌아온다.
+ await Assert.ThrowsAsync(() => rig.Stream.StartAsync(Ct));
+ await rig.Stream.StopAsync(Ct);
+ var writes = rig.Regs.Writes;
+ Assert.Contains((GvbsAddr.StreamChannel(0, GvbsAddr.ScpOffset), 0u), writes);
+ Assert.Contains((GvbsAddr.StreamChannel(0, GvbsAddr.ScdaOffset), 0u), writes);
+ Assert.False(rig.Stream.IsStarted);
+ Assert.Equal(opt.BufferCount, rig.Stream.PoolFreeBuffers);
+ }
+
[Fact]
public void ReceiveBeforeStartThrows()
{
@@ -523,6 +613,25 @@ public async Task FailedRegisterWriteDuringStartResetsScpAndClosesSocket()
Assert.Throws(() => rig.Stream.TryReceive(out _));
}
+ [Fact]
+ public async Task FailedSocketCreationLeavesTheStreamStopped()
+ {
+ // 소켓 생성은 핸들·버퍼가 바닥나면 던진다. 그때 스트림이 "시작 중" 에 걸려 있으면 다시 시작하려는 쪽은
+ // "이미 시작됨" 이라는 엉뚱한 답을 받고, 정지는 아무것도 쓴 적 없는 장치에 SCP/SCDA = 0 을 보낸다.
+ await using var rig = new StreamRig();
+ rig.Stream.SocketFactory = () => throw new System.Net.Sockets.SocketException((int)System.Net.Sockets.SocketError.NoBufferSpaceAvailable);
+
+ await Assert.ThrowsAsync(() => rig.Stream.StartAsync(Ct));
+ Assert.False(rig.Stream.IsStarted);
+ Assert.Throws(() => rig.Stream.TryReceive(out _));
+
+ var retry = await Assert.ThrowsAsync(() => rig.Stream.StartAsync(Ct));
+ Assert.Contains("cannot be restarted", retry.Message);
+
+ await rig.Stream.StopAsync(Ct);
+ Assert.Empty(rig.Regs.Writes);
+ }
+
[Fact]
public async Task ScpWriteFailingAfterSendIsStillReset()
{
@@ -546,10 +655,13 @@ public async Task SlowSenderDoesNotTriggerSpuriousResends()
// 침묵 규칙(재요청 간격만큼 조용하면 꼬리를 구멍으로 친다)이 스케줄링 지연에 걸리지 않게 간격을 넉넉히 둔다 — 여기서 보는 것은 유예뿐이다.
var opt = StreamRig.DefaultOpt();
// 프레임 전체가 25 ms 안에 나가므로 문턱을 크게 잡아도 "아직 안 온 꼬리는 구멍이 아니다" 라는 성질은 그대로 걸린다.
- // 문턱이 러너의 선점보다 짧으면 이 테스트는 유예가 아니라 러너의 스케줄링을 재게 된다.
- opt.PacketTimeoutMs = 1000;
+ // 문턱이 러너의 선점보다 짧으면 이 테스트는 유예가 아니라 러너의 스케줄링을 재게 된다 — 송신을 프레임 도중 1.1 s 멈추면
+ // 1 s 문턱의 침묵 규칙이 꼬리를 물어 요청이 1 건 나간다(주입으로 재현). 과부하 러너의 멈춤이 그만큼 길어질 수 있어 문턱을 더 올린다.
+ // 프레임은 마지막 페이로드에서 닫히므로 문턱을 더 올려도 이 시험은 느려지지 않는다.
+ // (스트레스 실행에서 실제로 잡힌 이 시험의 실패는 이 경로가 아니라 아래에 적은 데이터그램 유실이었다.)
+ opt.PacketTimeoutMs = 10_000;
// 보존 시간도 마찬가지 — 러너가 밀려 프레임이 포기되면 "군더더기 요청이 없다" 대신 타임아웃이 난다.
- opt.FrameRetentionMs = 3000;
+ opt.FrameRetentionMs = 20_000;
await using var rig = new StreamRig(opt);
await rig.StartAsync();
@@ -561,6 +673,24 @@ public async Task SlowSenderDoesNotTriggerSpuriousResends()
using var received = await rig.ReceiveAsync();
Assert.True(received.IsComplete);
Assert.True(received.Data.Span.SequenceEqual(frame.Data));
+ // 요청 수는 수신기가 보낸 패킷(트레일러까지)을 다 센 뒤에 본다 — 프레임을 받은 순간에는 트레일러가 아직 소켓에 있을 수 있다.
+ // 끝내 다 세지 못하면 데이터그램 하나가 스트림 소켓까지 와서 세지기 전에 사라진 것이다. 그때의 요청은 그 유실을 메운 것이라
+ // 군더더기는 아니지만, 이 시험은 그것도 실패로 남긴다: 옛 수신 대기(블로킹 수신 + 수신 시한)는 윈도우에서 시한 만료 순간 막
+ // 도착한 데이터그램을 잃었고(실기로 잼), 패킷 사이마다 대기가 한 번씩 끝나는 이 시험이 그런 유실이 드러나는 자리다.
+ // 지금의 대기(논블로킹 수신 + Poll)에서는 나오지 않아야 한다. 실패 메시지가 두 경우(수신 쪽 유실 / 군더더기 요청)를 가른다.
+ try
+ {
+ await rig.WaitUntilAsync(() => rig.Stream.Stats.PacketsReceived >= rig.Sender.PacketsSent, 2000);
+ }
+ catch (TimeoutException)
+ {
+ var s = rig.Stream.Stats.Snapshot();
+ var requests = string.Join("; ", rig.Resend.Requests.Select(r => $"{r.First}..{r.Last}"));
+ Assert.Fail($"The receiver counted {s.PacketsReceived} of the {rig.Sender.PacketsSent} datagrams sent ({s.PacketsResent} of them resend copies); "
+ + $"resend requests [{requests}]. A datagram reached the stream socket and was lost before it was counted, so a request here repairs a real "
+ + "loss rather than being spurious. The receiver waits with a non-blocking receive plus Poll precisely so that no datagram is lost at the end "
+ + "of a wait (a timed-out blocking receive lost them on Windows; see docs/evaluation.md, 'Receive wait on Windows'), so this points at a new loss path.");
+ }
Assert.Equal(0, rig.Resend.RequestCount);
Assert.Equal(0, rig.Stream.Stats.ResendRequests);
}
@@ -761,6 +891,50 @@ public async Task ResendDisabledDropsIncompleteFramesQuickly()
Assert.Equal(1, rig.Stream.Stats.FramesIncomplete);
}
+ [Fact]
+ public async Task SkippedFrameWithoutATrailerIgnoresRetentionWhenResendIsOff()
+ {
+ // 버리기로 한 프레임(여기서는 지원하지 않는 payload_type 4 의 12바이트 리더)도 트레일러가 오거나 조용해질 때까지 슬롯을 쥔다.
+ // 리센드가 꺼져 있으면 기다릴 리센드가 없으므로 PacketTimeoutMs 에 닫아야 한다 — 시작 로그와 옵션 설명이 "FrameRetentionMs 는
+ // 쓰이지 않는다" 고 알리는데 이 자리만 보존 시간을 쓰면, FrameDropped 와 버림 계수기가 그만큼 늦고 그동안 조립 슬롯 하나가 묶인다.
+ // 리센드가 켜진 쪽은 같은 시험 안의 대조군이다 — 같은 프레임이 보존 시간까지 기다리는 것을 함께 재서 시계가 살아 있음을 보인다.
+ const int packetTimeoutMs = 300;
+ const int offRetentionMs = 3000;
+ const int onRetentionMs = 1500;
+
+ var off = await MeasureSkippedFrameCloseAsync(resendEnabled: false, packetTimeoutMs, offRetentionMs);
+ var on = await MeasureSkippedFrameCloseAsync(resendEnabled: true, packetTimeoutMs, onRetentionMs);
+
+ // 닫는 시각은 "마지막 패킷 + 시한" 이하로 내려가지 않는다(하한은 과부하에도 흔들리지 않는다). 위쪽 상한은 보존 시간의 절반이라
+ // 보존 시간을 쓰던 판(≈ 3000 ms)과 한참 떨어져 있다.
+ Assert.True(off >= packetTimeoutMs - 10 && off < offRetentionMs / 2,
+ $"resend off: the skipped frame closed after {off} ms; expected about PacketTimeoutMs ({packetTimeoutMs} ms), not FrameRetentionMs ({offRetentionMs} ms) "
+ + "— GevStream.Receiver.cs CheckCompletion must give up on a skipped frame after PacketTimeoutMs when resend is off");
+ Assert.True(on >= onRetentionMs - 10,
+ $"resend on (control): the skipped frame closed after {on} ms; it should wait for FrameRetentionMs ({onRetentionMs} ms)");
+ }
+
+ /// 지원하지 않는 종류의 리더 한 장만 보내고 FrameDropped 가 올 때까지의 시간을 잰다.
+ private static async Task MeasureSkippedFrameCloseAsync(bool resendEnabled, int packetTimeoutMs, int retentionMs)
+ {
+ var opt = StreamRig.DefaultOpt();
+ opt.ResendEnabled = resendEnabled;
+ opt.PacketTimeoutMs = packetTimeoutMs;
+ opt.FrameRetentionMs = retentionMs;
+ await using var rig = new StreamRig(opt);
+ await rig.StartAsync();
+
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ rig.Sender.SendShortLeader(1, GvspConst.PayloadChunkData, dataBytes: 12); // 트레일러는 끝내 오지 않는다
+ var diag = await rig.WaitDroppedAsync();
+ sw.Stop();
+
+ Assert.Equal(1UL, diag.FrameId);
+ Assert.Equal(GevFrameDropReason.Unsupported, diag.Reason);
+ Assert.Equal(1, rig.Stream.Stats.FramesDroppedUnsupported);
+ return sw.ElapsedMilliseconds;
+ }
+
[Fact]
public async Task LargerLeaderGrowsTheBuffersLazily()
{
@@ -944,6 +1118,72 @@ public async Task RestartedBlockNumberingOpensNewFrames()
Assert.Equal(0, rig.Stream.Stats.PacketsIgnored);
}
+ [Fact]
+ public async Task RestartedBlockAfterALeaderOnlyFrameIsAssembledWithItsOwnLeader()
+ {
+ // 노출이 긴 촬영에서는 리더가 먼저 오므로 리더만 온 가장 새 프레임은 보존 시간이 지나도 기다린다. 그 사이 장치가 그 블록을
+ // 버리고(정지) 촬영을 다시 시작해 같은 블록 번호로 새 리더를 보내면, 그 리더를 중복으로 버리고 새 페이로드를 옛 리더의
+ // 슬롯에 실어 옛 타임스탬프·기하로 완성 처리하게 된다 — 틀린 값이 정상처럼 보인다.
+ var opt = StreamRig.DefaultOpt();
+ await using var rig = new StreamRig(opt);
+ await rig.StartAsync();
+
+ // 옛 리더는 64x100, 새 프레임은 128x50 — 바이트 수가 같아(6400) 옛 기하로 실어도 "다 받았다" 가 된다.
+ var aborted = rig.Sender.BuildFrame(1, 64, 100, Mono8, seed: 0x10, timestamp: 111_000);
+ rig.Sender.SendPacket(aborted, 0, GvspConst.StatusSuccess);
+ await rig.WaitUntilAsync(() => rig.Stream.Stats.PacketsReceived >= 1);
+ // 재요청 간격과 보존 시간을 둘 다 넘겨 쉰다 — 리더만 온 프레임은 그래도 붙들려 있다.
+ await Task.Delay(opt.FrameRetentionMs + 5 * opt.PacketTimeoutMs, Ct);
+
+ var restarted = rig.Sender.BuildFrame(1, 128, 50, Mono8, seed: 0x20, timestamp: 222_000);
+ Assert.Equal(aborted.Data.Length, restarted.Data.Length);
+ rig.Sender.SendFrame(restarted);
+
+ using var frame = await rig.ReceiveAsync();
+ Assert.Equal(1UL, frame.FrameId);
+ Assert.Equal(222_000UL, frame.Timestamp);
+ Assert.Equal(128, frame.Width);
+ Assert.Equal(50, frame.Height);
+ Assert.Equal(128, frame.Stride);
+ Assert.True(frame.IsComplete);
+ Assert.True(frame.Data.Span.SequenceEqual(restarted.Data));
+ Assert.Equal(0, rig.Stream.Stats.PacketsDuplicated);
+
+ // 버려진 옛 프레임은 조용히 사라지지 않는다 — 불완전 한 장으로 세고 알린다.
+ var diag = await rig.WaitDroppedAsync();
+ Assert.Equal(1UL, diag.FrameId);
+ Assert.Equal(GevFrameDropReason.Incomplete, diag.Reason);
+ Assert.Equal(1, rig.Stream.Stats.FramesIncomplete);
+ Assert.Equal(1, rig.Stream.Stats.FramesCompleted);
+ Assert.False(rig.Stream.TryReceive(out _));
+ }
+
+ [Fact]
+ public async Task LateCopyOfALeaderOnlyFramesLeaderIsStillADuplicate()
+ {
+ // 위 규칙의 반대편: 리더만 온 프레임에 같은 리더(같은 타임스탬프)가 한참 뒤에 다시 오면 새 촬영이 아니라 늦은 사본이다.
+ // 그것으로 프레임을 다시 열면 버리지 말아야 할 프레임을 불완전으로 세게 된다.
+ var opt = StreamRig.DefaultOpt();
+ await using var rig = new StreamRig(opt);
+ await rig.StartAsync();
+
+ var sent = rig.Sender.BuildFrame(1, 64, 100, Mono8, seed: 0x30, timestamp: 333_000);
+ rig.Sender.SendPacket(sent, 0, GvspConst.StatusSuccess);
+ await rig.WaitUntilAsync(() => rig.Stream.Stats.PacketsReceived >= 1);
+ await Task.Delay(5 * opt.PacketTimeoutMs, Ct);
+ rig.Sender.SendPacket(sent, 0, GvspConst.StatusSuccess);
+ await rig.WaitUntilAsync(() => rig.Stream.Stats.PacketsDuplicated >= 1);
+ for (uint id = 1; id <= sent.TrailerId; id++) rig.Sender.SendPacket(sent, id, GvspConst.StatusSuccess);
+
+ using var frame = await rig.ReceiveAsync();
+ Assert.Equal(333_000UL, frame.Timestamp);
+ Assert.True(frame.IsComplete);
+ Assert.True(frame.Data.Span.SequenceEqual(sent.Data));
+ Assert.Equal(1, rig.Stream.Stats.PacketsDuplicated);
+ Assert.Equal(0, rig.Stream.Stats.FramesIncomplete);
+ Assert.Equal(0, rig.DroppedCount);
+ }
+
[Fact]
public async Task DuplicateAllInPacketIsCountedNotReassembled()
{
@@ -1097,7 +1337,11 @@ public async Task RepeatedRequestsForTheSameHoleDoNotSpendTheBudgetTwice()
[Fact]
public async Task MalformedTrailerDoesNotPinTheFrameOpen()
{
- await using var rig = new StreamRig();
+ var opt = StreamRig.DefaultOpt();
+ // 리더 없이 페이로드만 받은 프레임은 보존 시간이 지나면 불완전으로 닫힌다 — 시험 스레드가 밀려도 리더를 돌려줄 때까지
+ // 열려 있게 넉넉히 둔다. 정상 흐름에서는 리더가 돌아오는 즉시 완성되므로 이 값만큼 기다리지 않는다.
+ opt.FrameRetentionMs = 5000;
+ await using var rig = new StreamRig(opt);
await rig.StartAsync();
// 첫 프레임으로 버퍼 크기를 알게 한 뒤, 둘째 프레임은 리더 없이 페이로드를 보내고 id 0 짜리 깨진 트레일러를 붙인다.
@@ -1108,17 +1352,25 @@ public async Task MalformedTrailerDoesNotPinTheFrameOpen()
Assert.True(f1.Data.Span.SequenceEqual(first.Data));
}
+ // 깨진 트레일러는 **열려 있는 프레임에** 닿아야 이 시험이 뜻을 가진다. 리센드 답을 붙들지 않으면 송신이 유예(2 ms)보다 늦게
+ // 트레일러에 닿는 순간 리더가 먼저 돌아와 프레임이 닫히고, 깨진 트레일러는 닫힌 블록의 늦은 트레일러로 조용히 지나간다
+ // (15 ms 멈춤으로 재현). 그래서 리더 요청에는 답하지 않다가, 수신기가 깨진 트레일러를 거른 것을 본 뒤에 답한다.
+ rig.Resend.Behaviour = TestResendPort.Mode.Never;
var second = rig.Sender.BuildFrame(2, 64, 100, Mono8, seed: 2);
rig.Sender.Drop.Add((2, 0));
for (uint id = 1; id <= (uint)second.PacketCount; id++) rig.Sender.SendPacket(second, id, GvspConst.StatusSuccess);
rig.Sender.SendTrailer(second, packetId: 0);
+ await rig.WaitUntilAsync(() => rig.Stream.Stats.PacketsIgnored >= 1);
+ Assert.Equal(1, rig.Stream.Stats.FramesCompleted); // 둘째 프레임은 아직 열려 있다 — 깨진 트레일러를 열린 프레임에서 걸렀다
+ rig.Resend.Behaviour = TestResendPort.Mode.Resend; // 다음 재요청(재요청 간격 뒤)이 리더를 받아 온다
+
using var frame = await rig.ReceiveAsync();
Assert.Equal(2UL, frame.FrameId);
Assert.True(frame.IsComplete);
Assert.Equal(second.PacketCount, frame.ExpectedPackets);
Assert.True(frame.Data.Span.SequenceEqual(second.Data));
- Assert.True(rig.Stream.Stats.PacketsIgnored >= 1);
+ Assert.Equal(1, rig.Stream.Stats.PacketsIgnored); // 걸러진 것은 깨진 트레일러 하나뿐이다
Assert.Equal(0, rig.Stream.Stats.FramesIncomplete);
}
@@ -1223,11 +1475,12 @@ public async Task TrailerWithASmallerHeightShrinksTheFrame()
/// 풀 버퍼 하나를 정상 프레임으로 한 번 채워 둔 스트림 — 다음 프레임이 같은 버퍼를 받으므로, 덜 온 자리에 이전 프레임의
/// 바이트가 남아 있으면 눈에 보인다(새 버퍼는 0 이라 그 오염이 가려진다).
///
- private static async Task<(StreamRig Rig, GvspTestSender.SynthFrame Previous)> StartWithDirtyBufferAsync(bool deliverIncomplete)
+ private static async Task<(StreamRig Rig, GvspTestSender.SynthFrame Previous)> StartWithDirtyBufferAsync(bool deliverIncomplete, Action? configure = null)
{
var opt = StreamRig.DefaultOpt();
opt.BufferCount = 1;
opt.DeliverIncompleteFrames = deliverIncomplete;
+ configure?.Invoke(opt);
var rig = new StreamRig(opt);
await rig.StartAsync();
var previous = rig.Sender.SendFrame(1, 64, 100, Mono8, seed: 0xAA);
@@ -1316,6 +1569,114 @@ public async Task BlockCutShortIsDroppedWhenIncompleteFramesAreNotDelivered()
}
}
+ [Fact]
+ public async Task BlockCutBeforeItsFirstPayloadClosesAtOnceAsIncomplete()
+ {
+ // 장치가 리더만 보내고 곧바로 id 1 의 트레일러로 블록을 끊었다(첫 페이로드 전에 멈췄다). 트레일러가 약속한 페이로드는 0 개라
+ // 더 올 것이 없다. 패킷 수 0 을 "아직 모름" 으로 읽으면 보존 시간 내내 기다리며 뒤 프레임을 막고, 불완전 프레임을 받겠다고 한
+ // 소비자에게도 끝내 나가지 않는다 — 한 패킷이라도 받은 뒤 끊긴 블록과 다르게 다룰 까닭이 없다.
+ var (rig, previous) = await StartWithDirtyBufferAsync(deliverIncomplete: true, opt => opt.FrameRetentionMs = 30_000);
+ await using (rig)
+ {
+ var cut = rig.Sender.BuildFrame(2, 64, 100, Mono8, seed: 0x11);
+ Assert.Equal(5, cut.PacketCount);
+ rig.Sender.SendPacket(cut, 0, GvspConst.StatusSuccess);
+ rig.Sender.SendTrailer(cut, 1);
+
+ // 보존 시간(30 초)까지 기다린다면 여기서 시한을 넘긴다.
+ using var frame = await rig.ReceiveAsync(3000);
+ Assert.Equal(2UL, frame.FrameId);
+ Assert.False(frame.IsComplete);
+ Assert.Equal(cut.Data.Length, frame.PayloadSize);
+ Assert.Equal(5, frame.ExpectedPackets);
+ Assert.Equal(5, frame.MissingPackets);
+ // 검사기가 살아 있는지: 버퍼는 이전 프레임으로 더럽혀 두었다 — 비우지 않으면 그 바이트가 그대로 나온다.
+ Assert.False(frame.Data.Span.SequenceEqual(previous.Data), "the frame still holds the previous frame");
+ Assert.True(frame.Data.Span.SequenceEqual(new byte[cut.Data.Length]), "nothing of this block arrived, so the frame must be all zeros");
+
+ var diag = await rig.WaitDroppedAsync();
+ Assert.Equal(2UL, diag.FrameId);
+ Assert.Equal(GevFrameDropReason.Incomplete, diag.Reason);
+ Assert.Equal(5, diag.MissingPackets);
+ Assert.Equal(1, rig.Stream.Stats.FramesIncomplete);
+ }
+ }
+
+ [Fact]
+ public async Task PacketStrideThatShrinksAfterBytesWereLaidDropsTheFrameAsError()
+ {
+ // 리더와 첫 페이로드(id 1)가 함께 유실되면 패킷당 바이트를 배울 근거가 없어 협상값(SCPS)에서 구한 간격으로 자리를 정한다.
+ // 장치가 그보다 짧은 패킷을 보내면 먼저 온 id 2.. 는 넓은 간격에 실리고, 리센드로 돌아온 id 1 이 진짜 간격을 알려 줄 때는
+ // 이미 늦었다. 그 뒤로 받은 패킷 수는 다 차고 받은 끝(가장 먼 끝)도 리더 크기를 넘으므로, 그대로 두면 어긋난 바이트와
+ // 그 사이에 남은 이전 프레임 바이트가 완성으로 나간다. 이미 실은 바이트는 옮길 수 없으니 프레임을 오류로 버려야 한다.
+ // 버퍼를 넉넉히 잡아 둔다 — 넓은 간격에서 뒤쪽 id 가 버퍼 밖으로 밀려나면 예산 초과로 버려져 이 경로에 오지 않는다.
+ var (rig, _) = await StartWithDirtyBufferAsync(deliverIncomplete: false, opt => opt.PayloadSize = 16384);
+ await using (rig)
+ {
+ rig.Sender.PacketSize = 1036;
+ var sent = rig.Sender.BuildFrame(2, 64, 100, Mono8, seed: 0x5A);
+ Assert.True(sent.DataBytesPerPacket < GvspConst.DataBytesPerPacket(rig.Stream.PacketSize, extendedIds: false));
+ Assert.Equal(7, sent.PacketCount);
+ rig.Sender.Drop.Add((2, 0));
+ rig.Sender.Drop.Add((2, 1));
+ rig.Sender.SendFrame(sent);
+
+ await rig.WaitUntilAsync(() => rig.Stream.QueuedFrames > 0 || rig.DroppedCount > 0);
+ if (rig.Stream.TryReceive(out var delivered) && delivered is not null)
+ {
+ using (delivered)
+ {
+ Assert.False(delivered.IsComplete && !delivered.Data.Span.SequenceEqual(sent.Data),
+ $"block {delivered.FrameId} was delivered complete with its bytes laid at the wrong packet stride");
+ }
+ }
+ var diag = await rig.WaitDroppedAsync();
+ Assert.Equal(2UL, diag.FrameId);
+ Assert.Equal(GevFrameDropReason.Error, diag.Reason);
+ Assert.Equal(1, rig.Stream.Stats.FramesDroppedError);
+ Assert.Equal(1, rig.Stream.Stats.FramesCompleted); // 더럽히려고 보낸 첫 프레임뿐
+
+ // 리더가 함께 오는 프레임은 실기 전에 id 1 로 간격을 배운다 — 같은 짧은 패킷이어도 그대로 완성되고, 버퍼도 풀로 돌아와 있다.
+ var next = rig.Sender.SendFrame(3, 64, 100, Mono8, seed: 0x33);
+ using var frame = await rig.ReceiveAsync();
+ Assert.Equal(3UL, frame.FrameId);
+ Assert.True(frame.IsComplete);
+ Assert.True(frame.Data.Span.SequenceEqual(next.Data));
+ }
+ }
+
+ [Fact]
+ public async Task PacketStrideThatGrowsAfterBytesWereLaidDropsTheChunkFrameAsError()
+ {
+ // 반대 방향: 장치가 협상값보다 긴 패킷을 보내는데 첫 페이로드(id 1)가 유실되고 짧은 마지막 패킷이 먼저 왔다. 마지막 패킷은
+ // 협상값 간격에 실리고, 리센드로 돌아온 id 1 이 더 긴 간격을 알려 줄 때는 이미 늦었다. 청크가 붙은 프레임은 리더가 크기를
+ // 알려 주지 못해 완성을 패킷 수로만 가리므로, 그대로 두면 마지막 패킷(청크 꼬리)을 잃은 프레임이 완성으로 나간다.
+ var (rig, _) = await StartWithDirtyBufferAsync(deliverIncomplete: false);
+ await using (rig)
+ {
+ rig.Sender.PacketSize = 3000;
+ var sent = rig.Sender.BuildChunkFrame(2, 64, 50, Mono8, chunkBytes: 400, seed: 0x5A);
+ Assert.True(sent.DataBytesPerPacket > GvspConst.DataBytesPerPacket(rig.Stream.PacketSize, extendedIds: false));
+ Assert.Equal(2, sent.PacketCount);
+ rig.Sender.Drop.Add((2, 1));
+ rig.Sender.SendFrame(sent);
+
+ await rig.WaitUntilAsync(() => rig.Stream.QueuedFrames > 0 || rig.DroppedCount > 0);
+ if (rig.Stream.TryReceive(out var delivered) && delivered is not null)
+ {
+ using (delivered)
+ {
+ Assert.False(delivered.IsComplete && !delivered.Data.Span.SequenceEqual(sent.Data),
+ $"block {delivered.FrameId} was delivered complete with {delivered.PayloadSize} of {sent.Data.Length} bytes, its last packet laid at the wrong packet stride");
+ }
+ }
+ var diag = await rig.WaitDroppedAsync();
+ Assert.Equal(2UL, diag.FrameId);
+ Assert.Equal(GevFrameDropReason.Error, diag.Reason);
+ Assert.Equal(1, rig.Stream.Stats.FramesDroppedError);
+ }
+ }
+
[Fact]
public async Task LeaderRecoveredAfterAShorterTrailerStillShrinksTheFrame()
{
@@ -1445,3 +1806,78 @@ public async Task StoppingReturnsTheBuffersOfFramesStillBeingAssembled()
Assert.Equal(0, rig.Stream.Stats.FramesIncomplete);
}
}
+
+///
+/// 스트림이 남기는 로그 줄 — 는 프로세스 전역이라 싱크를 바꿔 끼는 동안 다른 테스트와 나란히 돌지 않는 컬렉션에 둔다.
+///
+[Collection(GevLogSinkCollection.Name)]
+public class GevStreamLogTests
+{
+ private const uint Mono8 = 0x01080001;
+
+ /// 싱크를 바꿔 끼운 채 본문을 돌리고, 그동안 남은 (레벨, 메시지) 를 돌려준다.
+ private static async Task<(GevLogLevel Level, string Message)[]> CaptureAsync(Func body)
+ {
+ var logged = new List<(GevLogLevel, string)>();
+ var previousSink = GevLog.Sink;
+ var previousLevel = GevLog.MinLevel;
+ GevLog.MinLevel = GevLogLevel.Debug;
+ GevLog.Sink = (level, _, message, _) =>
+ {
+ lock (logged) logged.Add((level, message));
+ };
+ try
+ {
+ await body();
+ }
+ finally
+ {
+ GevLog.Sink = previousSink;
+ GevLog.MinLevel = previousLevel;
+ }
+ lock (logged) return logged.ToArray();
+ }
+
+ [Fact]
+ public async Task BlockCutBeforeItsFirstPayloadIsReportedLikeAnyCutBlock()
+ {
+ // 첫 페이로드 전에 끊긴 블록도 끊긴 블록이다 — 같은 경고가 한 번 나가야 "장치가 블록을 끊는다" 가 현장 로그에 보인다.
+ var logged = await CaptureAsync(async () =>
+ {
+ var opt = StreamRig.DefaultOpt();
+ opt.FrameRetentionMs = 30_000;
+ await using var rig = new StreamRig(opt);
+ await rig.StartAsync();
+
+ var cut = rig.Sender.BuildFrame(2, 64, 100, Mono8, seed: 0x11);
+ rig.Sender.SendPacket(cut, 0, GvspConst.StatusSuccess);
+ rig.Sender.SendTrailer(cut, 1);
+ await rig.WaitDroppedAsync(3000);
+ });
+
+ Assert.Contains(logged, l => l.Level == GevLogLevel.Warn && l.Message.Contains("the trailer ended the block after 0 payload packet(s)"));
+ }
+
+ [Theory]
+ [InlineData(true, 0.25, false)]
+ [InlineData(false, 0.25, true)]
+ [InlineData(true, 0.0, true)]
+ public async Task StartSaysWhenFrameRetentionDoesNotApply(bool resendEnabled, double ratio, bool isResendOff)
+ {
+ // 리센드가 꺼지면 보존 시간은 쓰이지 않는다 — 비율 0 도 그렇다. 옵션만 보고 보존 시간을 늘린 사람이 로그에서 이유를 찾을 수 있어야 하고,
+ // 시작 줄의 "resend on/off" 도 옵션 하나가 아니라 실제로 도는 쪽을 말해야 한다.
+ var logged = await CaptureAsync(async () =>
+ {
+ var opt = StreamRig.DefaultOpt();
+ opt.ResendEnabled = resendEnabled;
+ opt.PacketRequestRatio = ratio;
+ await using var rig = new StreamRig(opt);
+ await rig.StartAsync();
+ });
+
+ var notes = logged.Where(l => l.Message.Contains("FrameRetentionMs") && l.Message.Contains("does not apply")).ToArray();
+ Assert.Equal(isResendOff ? 1 : 0, notes.Length);
+ if (isResendOff) Assert.Equal(GevLogLevel.Info, notes[0].Level);
+ Assert.Contains(logged, l => l.Message.StartsWith("Stream started") && l.Message.EndsWith(isResendOff ? "resend off." : "resend on."));
+ }
+}
diff --git a/tests/GevSharp.Tests/Gvsp/HostileDeviceTests.cs b/tests/GevSharp.Tests/Gvsp/HostileDeviceTests.cs
index 4a4ee2d..13e6e7a 100644
--- a/tests/GevSharp.Tests/Gvsp/HostileDeviceTests.cs
+++ b/tests/GevSharp.Tests/Gvsp/HostileDeviceTests.cs
@@ -103,6 +103,10 @@ public async Task ErrorPacketWithAnImpossiblePacketIdStopsTheFrameInsteadOfAlloc
// 이 프레임이 가질 수 있는 패킷 수(2)를 한참 넘는 id 로 답한다.
rig.Sender.SendError(1, 200_000, GvcpConst.StatusPacketUnavailable);
+ // 기준 요청 수는 수신기가 그 오류 패킷을 처리한 뒤에 센다. 보낸 직후에 세면 수신 스레드가 오류 패킷을 꺼내기 전에 재요청 마감이
+ // 먼저 돌아 한 번 더 묻는 것이 "오류 뒤의 요청" 으로 잘못 세인다(수신 스레드의 틱을 25 ms 늦추는 주입으로 1 대 2 재현).
+ // 오류 패킷 계수기는 그 패킷을 다루는 첫 줄에서 오르고, 리센드를 끄는 처리는 같은 스레드에서 요청 없이 바로 뒤따른다.
+ await rig.WaitUntilAsync(() => rig.Stream.Stats.ErrorPackets >= 1);
var requestsAfterError = rig.Resend.RequestCount;
// 더는 묻지 않는다 — 재요청 간격을 여러 번 지나도 요청 수가 늘지 않아야 한다.
diff --git a/tests/GevSharp.Tests/Gvsp/ReceiveWaitLossTests.cs b/tests/GevSharp.Tests/Gvsp/ReceiveWaitLossTests.cs
new file mode 100644
index 0000000..149d9b9
--- /dev/null
+++ b/tests/GevSharp.Tests/Gvsp/ReceiveWaitLossTests.cs
@@ -0,0 +1,91 @@
+using GevSharp.Gvsp;
+
+#pragma warning disable xUnit1051
+
+namespace GevSharp.Tests.Gvsp;
+
+///
+/// 수신 대기 방식이 데이터그램을 잃지 않는지 — 부하 아래에서만 드러나는 성질이라 환경변수 GEVSHARP_STRESS=1 일 때만 돈다.
+///
+/// 수신 스레드는 프레임을 조립하는 동안 짧은 간격(max(1, min(InitialPacketTimeoutMs, PacketTimeoutMs)) ms)으로 깨어나 구멍을 본다.
+/// 그 기다림을 소켓 수신 시한(SO_RCVTIMEO)으로 만들면, 윈도우에서는 시한이 만료되는 순간 막 도착한 데이터그램이 사라질 수 있다 —
+/// 시한 만료 뒤 소켓 상태는 정해지지 않는다. 패킷 사이 간격이 그 시한보다 긴 송신(느린 장치, 리더가 먼저 오는 긴 노출,
+/// 대역을 나눠 쓰는 여러 카메라)에서 그 경계를 자주 밟고, CPU 가 바쁘면 더 자주 밟는다. 재전송이 켜져 있으면 요청 한 번으로
+/// 가려지므로 여기서는 끄고 보낸 수와 받은 수를 그대로 맞춘다.
+///
+///
+/// 이 루프백 시험은 옛 수신 대기(수신 시한)에서도 2,800 프레임 동안 유실을 재현하지 못했다 — 근거는 실기 측정이다
+/// (docs/evaluation.md 「Receive wait on Windows」: 패킷 간격 2.4 ms·재전송 끔·부하에서 옛 대기 불완전 9/46·8/39, 지금 0/39·0/39).
+/// 여기 남기는 것은 같은 조건을 다시 걸어 볼 수 있는 자리다.
+///
+///
+public class ReceiveWaitLossTests
+{
+ private const uint Mono8 = 0x01080001;
+
+ [Fact]
+ public async Task SlowSenderUnderCpuLoad_LosesNoDatagram()
+ {
+ Assert.SkipUnless(Environment.GetEnvironmentVariable("GEVSHARP_STRESS") == "1", "GEVSHARP_STRESS is not set to 1; the load test is skipped.");
+
+ var opt = StreamRig.DefaultOpt();
+ opt.ResendEnabled = false; // 유실이 재전송으로 가려지지 않게
+ opt.InitialPacketTimeoutMs = 2; // 조립 중 수신 대기가 2 ms 간격으로 깨어난다
+ opt.PacketTimeoutMs = 200; // 5 ms 간격의 패킷을 침묵으로 오판해 프레임을 닫지 않게
+ opt.BufferCount = 16;
+ await using var rig = new StreamRig(opt);
+ await rig.StartAsync();
+
+ var received = 0;
+ using var consumerStop = new CancellationTokenSource();
+ var consumer = Task.Run(async () =>
+ {
+ try
+ {
+ while (true)
+ {
+ using var f = await rig.Stream.ReceiveAsync(consumerStop.Token);
+ Interlocked.Increment(ref received);
+ }
+ }
+ catch (OperationCanceledException) { }
+ catch (GevStreamClosedException) { }
+ });
+
+ var stopBurn = false;
+ var burners = new Thread[Environment.ProcessorCount];
+ for (var i = 0; i < burners.Length; i++)
+ {
+ burners[i] = new Thread(() => { while (!Volatile.Read(ref stopBurn)) Thread.SpinWait(2000); }) { IsBackground = true };
+ burners[i].Start();
+ }
+
+ const int Frames = 400;
+ try
+ {
+ for (var i = 0; i < Frames; i++)
+ {
+ var frame = rig.Sender.BuildFrame((ulong)(i % 65000) + 1, 64, 40, Mono8, seed: (byte)i); // 2560 바이트 = 페이로드 2 패킷
+ for (uint id = 0; id <= frame.TrailerId; id++)
+ {
+ rig.Sender.SendPacket(frame, id, GvspConst.StatusSuccess);
+ Thread.Sleep(5);
+ }
+ }
+ }
+ finally
+ {
+ Volatile.Write(ref stopBurn, true);
+ foreach (var t in burners) t.Join();
+ }
+
+ await rig.WaitUntilAsync(() => rig.Stream.Stats.PacketsReceived >= rig.Sender.PacketsSent, 3000).ContinueWith(_ => { });
+ await Task.Delay(300);
+ var s = rig.Stream.Stats.Snapshot();
+ consumerStop.Cancel();
+ await consumer;
+
+ Assert.True(s.PacketsReceived == rig.Sender.PacketsSent && s.FramesIncomplete == 0,
+ $"sent {rig.Sender.PacketsSent} datagrams, received {s.PacketsReceived}; frames completed {s.FramesCompleted}, incomplete {s.FramesIncomplete}, delivered {Volatile.Read(ref received)} of {Frames}");
+ }
+}
diff --git a/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs b/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs
index b0de020..d9c4f4b 100644
--- a/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs
+++ b/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs
@@ -277,6 +277,47 @@ public async Task Heartbeat_UnreachableDevice_RaisesControlLostAndClosesSession(
}
}
+ [Fact]
+ public async Task SimReboot_DropsControl_HostReportsARestart_AndANewSessionTakesOver()
+ {
+ // 전원을 껐다 켠 장치: 주소·포트는 그대로, CCP 는 0, 휘발 상태는 켜진 직후. 호스트의 다음 하트비트가 CCP = 0 을 읽는다 —
+ // 마지막 하트비트가 장치 시한(10 s)보다 한참 전이 아니므로 사유는 "다른 애플리케이션이 놓았거나 가져갔거나, 장치가 재시작" 이어야 한다.
+ await using var rig = await SimRig.StartAsync(device: o => o.HeartbeatPeriodMs = 100);
+ var lost = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ rig.Device.ControlLost += (_, ex) => lost.TrySetResult(ex);
+ var owners = new List();
+ rig.Sim.ControlOwnerChanged += o => { lock (owners) owners.Add(o); };
+ var endPoint = rig.EndPoint;
+
+ await rig.Device.WriteRegAsync(SimFeatureAddr.Width, 256);
+ await rig.Device.WriteRegAsync(GvbsAddr.StreamChannel(0, GvbsAddr.ScpOffset), 50_000);
+ await rig.Device.WriteRegAsync(SimFeatureAddr.AcquisitionStart, 1);
+ Assert.True(rig.Sim.IsAcquiring);
+
+ rig.Sim.Reboot();
+
+ Assert.Equal(endPoint, rig.Sim.GvcpEndPoint); // 같은 자리로 돌아온다 — 호스트가 그대로 닿는다
+ Assert.Null(rig.Sim.ControlOwner);
+ Assert.Equal(0u, rig.Sim.Registers.ReadU32(GvbsAddr.Ccp));
+ Assert.Equal(0u, rig.Sim.Registers.ReadU32(GvbsAddr.PrimaryAppPort));
+ lock (owners) Assert.Equal(new IPEndPoint?[] { null }, owners);
+ Assert.False(rig.Sim.IsAcquiring);
+ Assert.Equal(128u, rig.Sim.Registers.ReadU32(SimFeatureAddr.Width)); // 피처·스트림 채널·하트비트 시한은 켜진 직후 값
+ Assert.Equal(0u, rig.ReadStreamReg(GvbsAddr.ScpOffset));
+ Assert.Equal((uint)rig.Sim.Opt.HeartbeatTimeoutMs, rig.Sim.Registers.ReadU32(GvbsAddr.HeartbeatTimeout));
+ Assert.Equal(0ul, rig.Sim.LastBlockId); // 다음 프레임은 블록 1
+
+ var done = await Task.WhenAny(lost.Task, Task.Delay(10_000));
+ Assert.True(ReferenceEquals(done, lost.Task), "ControlLost did not fire after the simulator rebooted");
+ var ex = Assert.IsType(await lost.Task);
+ Assert.Contains("device restarted", ex.Message);
+ Assert.False(rig.Device.IsOpen);
+
+ // 재부팅한 장치는 새 세션(다른 소켓)이 기다림 없이 잡는다.
+ await using var next = await GevDevice.OpenAsync(rig.EndPoint, SimRig.DefaultDeviceOpt());
+ Assert.Equal(next.Gvcp.LocalEndPoint, rig.Sim.ControlOwner);
+ }
+
// ---------------------------------------------------------------- dispose
[Fact]
@@ -310,6 +351,69 @@ public async Task Dispose_ReleasesCcp_AndLetsTheNextSessionTakeControl()
Assert.NotEqual(first, next.Gvcp.LocalEndPoint);
}
+ [Fact]
+ public async Task Dispose_NodeMapTakenBefore_ThrowsObjectDisposedOnTheNextDeviceAccess()
+ {
+ // 오류 계약(architecture.md)이 적는 대로: 닫힌 뒤의 조작은 GevException 이 아니라 ObjectDisposedException 이다 —
+ // 앞서 받아 둔 노드맵도 포트가 이 장치라 같다. GenApi 층이 그것을 GenApiException 으로 감싸지 않는지까지 본다.
+ await using var rig = await SimRig.StartAsync();
+ var nodes = await rig.Device.GetNodeMapAsync();
+ var xml = await rig.Device.GetXmlAsync();
+ var width = nodes.GetInteger("Width");
+ Assert.Equal(128, await width.GetAsync());
+
+ await rig.Device.DisposeAsync();
+
+ // 세션 동안 받아 둔 것(XML·노드맵)은 닫힌 뒤에도 캐시에서 그대로 돌려준다 — 막히는 것은 장치에 닿는 조작이다.
+ Assert.Same(nodes, await rig.Device.GetNodeMapAsync());
+ Assert.Same(xml, await rig.Device.GetXmlAsync());
+ await Assert.ThrowsAsync(() => width.SetAsync(256).AsTask());
+ await Assert.ThrowsAsync(() => rig.Device.ReadRegAsync(GvbsAddr.Version));
+ await Assert.ThrowsAsync(() => rig.Device.OpenStreamAsync());
+ }
+
+ [Fact]
+ public async Task GvcpChannelClosedUnderAnOpenDevice_FlipsTheSessionToControlLostAtOnce()
+ {
+ // 수신 소켓이 회복 불가로 실패하면 채널은 스스로 Dispose() 한다. 그 소켓 오류를 루프백에서 일으킬 방법이 없어
+ // 같은 메서드를 밖에서 불러 닫힌 뒤의 상태를 만든다(누군가 device.Gvcp 를 직접 닫는 경우와도 같다).
+ // 하트비트 주기를 3 s 로 둔다 — 세 번 실패(9 s)를 기다려서야 상태가 바뀌는 회귀라면 아래 단정이 그 전에 걸린다.
+ await using var rig = await SimRig.StartAsync(device: o => { o.HeartbeatTimeoutMs = 30_000; o.HeartbeatPeriodMs = 3000; });
+ var lost = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ rig.Device.ControlLost += (_, ex) => lost.TrySetResult(ex);
+
+ rig.Device.Gvcp.Dispose();
+
+ // 열려 있다고 답하면서 모든 조작이 ObjectDisposedException 으로 끝나는 창이 없어야 한다.
+ var ex = await Record.ExceptionAsync(() => rig.Device.ReadRegAsync(GvbsAddr.Version));
+ Assert.IsType(ex);
+ Assert.Contains("GVCP control channel", ex!.Message);
+ Assert.False(rig.Device.IsOpen);
+
+ var done = await Task.WhenAny(lost.Task, Task.Delay(10_000));
+ Assert.True(ReferenceEquals(done, lost.Task), "ControlLost did not fire after the GVCP channel closed under the open device");
+ Assert.IsAssignableFrom(await lost.Task);
+ }
+
+ [Fact]
+ public async Task GvcpChannelClosedUnderAReadOnlySession_AlsoEndsTheSession()
+ {
+ // 읽기 전용 세션은 하트비트가 없다 — 채널이 닫혀도 상태를 바꿔 줄 것이 달리 없어, 이 경로가 없으면 영영 "열림" 이다.
+ await using var rig = await SimRig.StartAsync();
+ var ro = SimRig.DefaultDeviceOpt();
+ ro.AccessMode = GevAccessMode.ReadOnly;
+ await using var reader = await GevDevice.OpenAsync(rig.EndPoint, ro);
+ Assert.Equal(0, reader.HeartbeatPeriodMs);
+
+ reader.Gvcp.Dispose();
+
+ await Assert.ThrowsAsync(() => reader.ReadRegAsync(GvbsAddr.Version));
+ Assert.False(reader.IsOpen);
+ // 다른 세션은 영향이 없다.
+ Assert.True(rig.Device.IsOpen);
+ Assert.Equal(0x0002_0000u, await rig.Device.ReadRegAsync(GvbsAddr.Version));
+ }
+
// ---------------------------------------------------------------- register / memory access
[Fact]
@@ -408,6 +512,86 @@ public async Task GetXml_WorksFromAReadOnlySession()
Assert.Equal(0, sim.WriteMemCount);
}
+ // ---------------------------------------------------------------- open by end point
+
+ [Fact]
+ public async Task OpenAsync_EndPoint_IsPublic_AndOpensADeviceOnANonStandardPort()
+ {
+ // 표준 포트가 아닌 곳의 장치(루프백 시뮬레이터, NAT 뒤 장치)를 공개 API 만으로 열 수 있어야 한다 — 내부 오버로드였던 것을 공개했다.
+ var method = typeof(GevDevice).GetMethod(nameof(GevDevice.OpenAsync), new[] { typeof(IPEndPoint), typeof(GevDeviceOpt), typeof(CancellationToken) });
+ Assert.NotNull(method);
+ Assert.True(method!.IsPublic);
+
+ using var sim = SimRig.StartSim();
+ Assert.NotEqual(3956, sim.GvcpEndPoint.Port);
+ await using var dev = await GevDevice.OpenAsync(sim.GvcpEndPoint, SimRig.DefaultDeviceOpt());
+ Assert.True(dev.IsOpen);
+ Assert.Equal(sim.GvcpEndPoint, dev.Gvcp.DeviceEndPoint);
+ Assert.Equal(0x0002_0000u, await dev.ReadRegAsync(GvbsAddr.Version));
+ }
+
+ [Fact]
+ public async Task OpenAsync_EndPoint_RejectsPortZero()
+ {
+ var ex = await Assert.ThrowsAsync(() => GevDevice.OpenAsync(new IPEndPoint(IPAddress.Loopback, 0)));
+ Assert.Equal("device", ex.ParamName);
+ }
+
+ [Fact]
+ public async Task OpenAsync_EndPoint_KeepsItsOwnCopy_SoReusingTheCallersEndPointChangesNothing()
+ {
+ // IPEndPoint 는 바뀌는 객체다. 호출자가 같은 객체를 다음 장치(다른 시뮬레이터·포워딩 포트)에 다시 쓰면,
+ // 송신 주소는 열 때 직렬화해 둔 사본이라 그대로인데 응답 대조가 호출자의 객체를 보고 있으면 진짜 장치의 응답이
+ // 전부 남의 패킷으로 버려진다 — 요청마다 시한 초과, 끝내 "하트비트 실패" 로 제어권 상실이 나고 원인은 어디에도 안 남는다.
+ using var sim = SimRig.StartSim();
+ var ep = new IPEndPoint(sim.GvcpEndPoint.Address, sim.GvcpEndPoint.Port);
+ await using var dev = await GevDevice.OpenAsync(ep, SimRig.DefaultDeviceOpt());
+ var foreignBefore = dev.Gvcp.ForeignPacketCount;
+
+ ep.Port = 1;
+ ep.Address = IPAddress.Parse("127.0.0.2");
+
+ // 동작 단정을 먼저 둔다 — 사본을 쥐지 않는 회귀는 여기서 GevTimeoutException 으로 드러난다.
+ Assert.Equal(0x0002_0000u, await dev.ReadRegAsync(GvbsAddr.Version));
+ Assert.Equal(foreignBefore, dev.Gvcp.ForeignPacketCount);
+ Assert.True(dev.IsOpen);
+ // 진단에 드러나는 주소(DeviceEndPoint·로그)도 연 장치 그대로다.
+ Assert.NotSame(ep, dev.Gvcp.DeviceEndPoint);
+ Assert.Equal(sim.GvcpEndPoint, dev.Gvcp.DeviceEndPoint);
+
+ // getter 로 받은 객체를 바꿔도 같다 — 채널이 응답 대조에 쓰는 사본을 밖으로 내주지 않는다.
+ var handedOut = dev.Gvcp.DeviceEndPoint;
+ handedOut.Port = 1;
+ Assert.Equal(0x0002_0000u, await dev.ReadRegAsync(GvbsAddr.Version));
+ Assert.Equal(foreignBefore, dev.Gvcp.ForeignPacketCount);
+ Assert.Equal(sim.GvcpEndPoint, dev.Gvcp.DeviceEndPoint);
+ }
+
+ [Fact]
+ public async Task OpenAsync_EndPoint_ThrowsWhatItsDocumentationLists()
+ {
+ // 공개 진입점의 목록이 코드와 어긋나지 않게 값싼 갈래를 못 박는다. 옵션 범위·무응답(GevDeviceTests)과
+ // 제어권 거절(SecondSession_Control_WhileFirstHoldsCcp_ThrowsControlLost), 포트 0(위)은 따로 시험한다.
+ Assert.Throws(() => { _ = GevDevice.OpenAsync((IPEndPoint)null!); }); // 태스크가 아니라 호출 자리에서
+
+ // IPv4 가 아닌 끝점: 로컬 주소를 스스로 정하는 자리(옵션에 없을 때)든 채널을 만드는 자리(옵션에 있을 때)든 GevException.
+ // 둘 다 소켓을 만들기 전에 끝나 아무것도 보내지 않는다.
+ var v6 = new IPEndPoint(IPAddress.IPv6Loopback, GvcpConst.Port);
+ await Assert.ThrowsAsync(() => GevDevice.OpenAsync(v6));
+ await Assert.ThrowsAsync(() => GevDevice.OpenAsync(v6, new GevDeviceOpt { LocalAddress = IPAddress.Loopback }));
+
+ using var sim = SimRig.StartSim();
+ // 옵션의 로컬 주소에 GVCP 소켓을 묶지 못하면 SocketException 이 감싸지 않고 나온다. 192.0.2.1 은 문서용 예약 주소(TEST-NET-1)라
+ // 이 호스트의 주소일 수 없다 — 묶는 데서 끝나고 아무것도 보내지 않는다.
+ await Assert.ThrowsAsync(
+ () => GevDevice.OpenAsync(sim.GvcpEndPoint, new GevDeviceOpt { LocalAddress = IPAddress.Parse("192.0.2.1") }));
+
+ await Assert.ThrowsAnyAsync(
+ () => GevDevice.OpenAsync(sim.GvcpEndPoint, SimRig.DefaultDeviceOpt(), new CancellationToken(canceled: true)));
+ Assert.Null(sim.ControlOwner); // 실패한 열기는 제어권을 남기지 않는다
+ Assert.Equal(0, sim.WriteRegCount);
+ }
+
// ---------------------------------------------------------------- stream vs. device lifetime
[Fact]
@@ -432,4 +616,61 @@ public async Task Stream_OutlivesItsDevice_WaitingReceiveEndsOnlyByTokenOrStop()
await stream.StopAsync(); // 장치에 SCP = 0 을 못 써도(닫힘) 로컬 정리는 끝까지 간다
await Assert.ThrowsAsync(() => waiting);
}
+
+ [Theory]
+ [InlineData(20, 300)] // 셧다운이 짧은 시한을 준다
+ [InlineData(20, 0)] // 시한이 이미 지난 토큰
+ [InlineData(int.MaxValue, 0)] // 끝없이 재시도하는 채널 — 채널 예산에 기대면 정지가 영영 돌아오지 않는다
+ public async Task Stream_StopAgainstASilentDevice_EndsWithinItsOwnWriteBudget(int gvcpRetries, int callerTokenMs)
+ {
+ // 스트림이 도는 중에 장치가 GVCP 에 답하지 않게 됐다(케이블이 빠졌거나 전원이 나갔다). 셧다운은 스트림을 멈추고 장치를 닫는다.
+ // 정지는 호출자의 토큰과 무관하게 장치 전송 끄기(SCP = 0, SCDA = 0)를 시도하는데, 그 두 쓰기가 채널의 재시도 예산 전부
+ // (쓰기 둘 × (1 + GvcpRetries) × GvcpTimeoutMs, 그 앞에 재시도 중인 하트비트 뒤의 줄서기까지)를 쓰면 호출자는 정지를 끊을
+ // 길이 없다. 두 쓰기는 호출자의 토큰에도 GvcpRetries 에도 기대지 않는 고정 예산 하나를 따로 받아야 한다.
+ const int gvcpTimeoutMs = 500;
+ var rig = await SimRig.StartAsync(device: o =>
+ {
+ o.GvcpTimeoutMs = gvcpTimeoutMs;
+ o.GvcpRetries = gvcpRetries;
+ });
+ var streamOpt = SimRig.DefaultStreamOpt();
+ var stream = await rig.OpenStreamAsync(streamOpt);
+ var budgetMs = GevDevice.ShutdownWriteBudgetMs(gvcpTimeoutMs);
+ Task? stop = null;
+ try
+ {
+ rig.Sim.Stop();
+
+ using var cts = new CancellationTokenSource();
+ if (callerTokenMs > 0) cts.CancelAfter(callerTokenMs);
+ else cts.Cancel();
+
+ var sw = Stopwatch.StartNew();
+ stop = stream.StopAsync(cts.Token);
+ // 회귀가 나도 시험이 매달리지 않게 기다림에만 상한을 둔다 — 정지 자체를 끊는 것이 아니다.
+ var done = await Task.WhenAny(stop, Task.Delay(30_000));
+ sw.Stop();
+
+ Assert.True(ReferenceEquals(done, stop),
+ $"StopAsync did not return within 30 s against a silent device (GvcpRetries {gvcpRetries}); GevStream.cs StopAsync must bound the SCP/SCDA writes with its own budget");
+ await stop; // 정지는 정상으로 돌아온다 — 취소 예외도 쓰기 실패도 밖으로 내지 않는다
+ // 예산 뒤에 남는 일은 소켓 닫기·수신 스레드 합류·큐 비우기뿐이라 즉시 끝난다. 상한은 예산에 과부하 여유를 얹은 값이고,
+ // 채널 예산에 기대던 판(쓰기 둘 × 21 회 × 500 ms ≈ 21 s, 재시도가 끝없으면 무한)과는 한참 떨어져 있다.
+ const int limitMs = 5000;
+ Assert.True(sw.ElapsedMilliseconds < limitMs,
+ $"StopAsync took {sw.ElapsedMilliseconds} ms against a silent device (write budget {budgetMs} ms, GvcpRetries {gvcpRetries}, caller token {callerTokenMs} ms)");
+ // 대조군: 장치가 정말 말이 없었다면 SCP 쓰기가 예산을 다 써야 한다. 이보다 빨리 끝났다면 장치가 답했거나 쓰기를 건너뛴 것이라
+ // 위 상한은 아무것도 재지 않은 셈이다.
+ Assert.True(sw.ElapsedMilliseconds >= budgetMs - 50,
+ $"StopAsync took only {sw.ElapsedMilliseconds} ms; with a silent device the SCP write should have used the {budgetMs} ms budget");
+ Assert.False(stream.IsStarted);
+ Assert.Equal(streamOpt.BufferCount, stream.PoolFreeBuffers);
+ }
+ finally
+ {
+ // 장치를 먼저 닫는다 — 정지가 채널 안에 걸려 있다면 채널을 닫아야 풀린다. 끝나지 않은 정지는 기다리지 않는다.
+ await rig.DisposeAsync();
+ if (stop is { IsCompleted: true }) await stream.DisposeAsync();
+ }
+ }
}
diff --git a/tests/GevSharp.Tests/Integration/TlParamsLockedTests.cs b/tests/GevSharp.Tests/Integration/TlParamsLockedTests.cs
new file mode 100644
index 0000000..bcbd3b2
--- /dev/null
+++ b/tests/GevSharp.Tests/Integration/TlParamsLockedTests.cs
@@ -0,0 +1,86 @@
+using GevSharp.GenApi;
+using GevSharp.Tests.GenApi.Model;
+
+// 테스트마다 자체 타임아웃을 두므로 xunit 취소 토큰 전달 권고(xUnit1051)는 끈다.
+#pragma warning disable xUnit1051
+
+namespace GevSharp.Tests.Integration;
+
+///
+/// 가 false 를 돌려주는 두 경우 — 노드가 없을 때와, 같은 이름의 노드가 정수 노드가 아닐 때 —
+/// 가 로그에서 서로 구분되는지. 뒤엣것은 그 노드에 걸린 잠금이 풀리지 않은 채 남는 이상 상황이라 "없다" 로 적히면 안 된다.
+/// 전역 싱크를 바꾸므로 격리 컬렉션에서 돌고, 싱크는 네트워크 왕복이 없는 호출(노드맵을 미리 받아 둔 뒤의 조회) 하나만 감싼다.
+///
+[Collection(GevLogSinkCollection.Name)]
+public class TlParamsLockedTests
+{
+ private const string Header =
+ ""
+ + ""
+ + "";
+
+ /// TLParamsLocked 를 정수가 아닌 Boolean 으로 선언한 기술.
+ private const string BooleanTlParamsLockedXml = Header
+ + "TLParamsLocked"
+ + "0"
+ + "";
+
+ /// TLParamsLocked 가 아예 없는 기술.
+ private const string NoTlParamsLockedXml = Header
+ + ""
+ + "";
+
+ [Fact]
+ public async Task NonIntegerNode_ReturnsFalse_AndWarnsWithItsKindInsteadOfCallingItMissing()
+ {
+ await using var rig = await SimRig.StartAsync(sim: o => o.GenApiXml = BooleanTlParamsLockedXml);
+ var nodes = await rig.Device.GetNodeMapAsync();
+ Assert.Equal(NodeKind.Boolean, nodes.GetNode("TLParamsLocked")!.Kind);
+
+ var (result, logged) = await CaptureAsync(() => rig.Device.SetTlParamsLockedAsync(true));
+
+ Assert.False(result);
+ var entry = Assert.Single(logged, e => e.Message.Contains("TLParamsLocked"));
+ Assert.DoesNotContain("not in the node map", entry.Message);
+ Assert.Contains("Boolean", entry.Message);
+ Assert.Equal(GevLogLevel.Warn, entry.Level);
+ }
+
+ [Fact]
+ public async Task MissingNode_ReturnsFalse_AndSaysSoAtDebug()
+ {
+ await using var rig = await SimRig.StartAsync(sim: o => o.GenApiXml = NoTlParamsLockedXml);
+ var nodes = await rig.Device.GetNodeMapAsync();
+ Assert.Null(nodes.GetNode("TLParamsLocked"));
+
+ var (result, logged) = await CaptureAsync(() => rig.Device.SetTlParamsLockedAsync(false));
+
+ Assert.False(result);
+ var entry = Assert.Single(logged, e => e.Message.Contains("TLParamsLocked"));
+ Assert.Contains("not in the node map", entry.Message);
+ Assert.Equal(GevLogLevel.Debug, entry.Level);
+ }
+
+ /// 호출 하나를 Debug 싱크로 감싼다. 노드맵은 이미 받아 두었으므로 이 호출은 장치와 왕복하지 않는다.
+ private static async Task<(bool Result, List<(GevLogLevel Level, string Message)> Logged)> CaptureAsync(Func> call)
+ {
+ var logged = new List<(GevLogLevel Level, string Message)>();
+ var prevSink = GevLog.Sink;
+ var prevLevel = GevLog.MinLevel;
+ bool result;
+ try
+ {
+ GevLog.Sink = (lvl, _, msg, _) => { lock (logged) logged.Add((lvl, msg)); };
+ GevLog.MinLevel = GevLogLevel.Debug;
+ result = await call();
+ }
+ finally
+ {
+ GevLog.Sink = prevSink;
+ GevLog.MinLevel = prevLevel;
+ }
+ lock (logged) return (result, logged.ToList());
+ }
+}
diff --git a/tests/GevSharp.Tests/Sim/SimGvcpTests.cs b/tests/GevSharp.Tests/Sim/SimGvcpTests.cs
index 436eef4..fb77ec8 100644
--- a/tests/GevSharp.Tests/Sim/SimGvcpTests.cs
+++ b/tests/GevSharp.Tests/Sim/SimGvcpTests.cs
@@ -405,6 +405,45 @@ public void Ccp_HeartbeatTimeoutZero_NeverExpires()
Assert.Equal(0, dev.HeartbeatTimeouts);
}
+ [Fact]
+ public void Reboot_ReportsTheReleaseBeforeANewOwnerCanTakeControl()
+ {
+ // 재부팅이 비운 제어권(null)은 그 뒤에 잡은 새 보유자보다 먼저 관찰자에게 닿아야 한다 — 뒤집혀 [새 보유자, null] 로 오면
+ // 관찰자는 누군가 쥐고 있는 장치를 "아무도 안 쥐었다" 로 읽는다. 앞에 느린 관찰자를 하나 세워 그 창을 넓힌다:
+ // 재부팅의 null 을 받는 자리에서 다른 호스트(B)가 CCP 를 쓰고 ACK 를 잠시 기다린다. null 을 명령 처리와 같은 잠금 밖에서
+ // 올리면 그 대기 동안 응답기가 B 의 쓰기를 처리해 B 가 먼저 기록되고, 잠금 안에서 올리면 B 의 쓰기는 그 뒤로 밀린다.
+ using var dev = StartDevice();
+ using var a = new RawGvcpClient(dev.GvcpEndPoint);
+ using var b = new RawGvcpClient(dev.GvcpEndPoint);
+ // 제어권 획득과 HeartbeatTimeout = 0 을 한 WRITEREG 에 — 굶주린 러너에서 재부팅 전에 A 가 만료돼 null 이 먼저 오는 일을 막는다.
+ Assert.Equal(GvcpConst.StatusSuccess, a.WriteRegs((GvbsAddr.Ccp, GvbsAddr.CcpControl), (GvbsAddr.HeartbeatTimeout, 0u)).Status);
+
+ var callerThread = Environment.CurrentManagedThreadId;
+ var nullThread = -1;
+ RawGvcpAck? ackInHandler = null;
+ var owners = new List();
+ dev.ControlOwnerChanged += owner =>
+ {
+ if (owner is not null || nullThread != -1) return;
+ nullThread = Environment.CurrentManagedThreadId;
+ b.SendRaw(RawGvcpClient.BuildCmd(GvcpConst.WriteRegCmd, GvcpConst.FlagAckRequired, b.NextReqId(),
+ RawGvcpClient.WriteRegPayload((GvbsAddr.Ccp, GvbsAddr.CcpControl))));
+ // ACK 가 오는지는 단정하지 않는다 — 고친 판에서는 응답기가 이 처리기가 끝나기를 기다리므로 여기서는 오지 않는 것이 정상이다.
+ // 이 대기는 null 을 잠금 밖에서 올리는 판이 경합에서 지게 만드는 몫이다.
+ ackInHandler = b.Receive(500);
+ };
+ dev.ControlOwnerChanged += owner => { lock (owners) owners.Add(owner); };
+
+ dev.Reboot();
+
+ var ack = ackInHandler ?? b.Receive() ?? throw new TimeoutException("no reply to the new host's CCP write");
+ Assert.Equal(GvcpConst.StatusSuccess, ack.Status);
+ // 응답기는 CCP 를 바꾸고 이벤트를 올린 뒤에 ACK 를 보내므로, ACK 를 받았으면 B 는 이미 기록돼 있다.
+ lock (owners) Assert.Equal(new IPEndPoint?[] { null, b.LocalEndPoint }, owners);
+ Assert.Equal(b.LocalEndPoint, dev.ControlOwner);
+ Assert.Equal(callerThread, nullThread); // 재부팅의 null 은 Reboot 를 부른 스레드에서 올라간다(이벤트 문서)
+ }
+
// ---- PENDING_ACK ----
[Fact]
@@ -579,9 +618,15 @@ public void TimestampLatch_CapturesRunningCounter()
using var dev = StartDevice();
using var c = new RawGvcpClient(dev.GvcpEndPoint);
- c.WriteRegOk(GvbsAddr.TimestampControl, 2); // reset
+ // 부트스트랩 0x0944 의 값은 1 = reset, 2 = latch 다 — 실제 장치의 기술(GevTimestampControlReset CommandValue 1,
+ // GevTimestampControlLatch CommandValue 2)이 이 값을 쓴다. 뒤바뀌면 호스트의 "래치" 가 카운터를 지운다.
+ // reset 은 래치 레지스터를 건드리지 않는다 — 시각과 무관하게 갈리는 단정이라 먼저 본다(뒤바뀐 장치는 여기서 0 이 아닌 값을 싣는다).
+ c.WriteRegOk(GvbsAddr.TimestampControl, 1); // reset
+ var (_, r) = c.ReadRegs(GvbsAddr.TimestampLatchedHigh, GvbsAddr.TimestampLatchedLow);
+ Assert.Equal(0ul, ((ulong)r[0] << 32) | r[1]);
+
Thread.Sleep(20);
- c.WriteRegOk(GvbsAddr.TimestampControl, 1); // latch
+ c.WriteRegOk(GvbsAddr.TimestampControl, 2); // latch
var (_, v) = c.ReadRegs(GvbsAddr.TimestampLatchedHigh, GvbsAddr.TimestampLatchedLow, GvbsAddr.TimestampControl);
ulong latched = ((ulong)v[0] << 32) | v[1];
@@ -591,6 +636,67 @@ public void TimestampLatch_CapturesRunningCounter()
Assert.Equal(0u, v[2]);
}
+ [Fact]
+ public void TimestampReset_RestartsTheRunningCounter()
+ {
+ // 위 시험은 시작 직후에 reset 하므로 reset 이 아무것도 안 해도 통과한다 — 여기서 그 둘을 가른다.
+ // 시뮬레이터는 같은 프로세스라 호스트의 Stopwatch 와 같은 시계로 센다. 그러니 reset 을 보내기 직전(h1)부터 latch 의 ACK 를
+ // 받은 뒤(h2)까지가 reset 뒤 래치 값의 상한이다. 부하가 늘리는 것은 이 괄호뿐이라 이 단정은 굶주린 러너에서도 흔들리지 않는다.
+ // reset 이 아무것도 안 하면 래치 값은 앞서 흘려 둔 시간(아래 ≥ 200 ms)을 싣고 괄호를 넘는다 — 두 왕복만으로 그보다 오래
+ // 걸리는 러너에서는 그 판별이 약해질 뿐 고친 판이 거짓으로 깨지지는 않는다.
+ using var dev = StartDevice();
+ using var c = new RawGvcpClient(dev.GvcpEndPoint);
+
+ Thread.Sleep(250); // 카운터를 흘려 둔다
+ c.WriteRegOk(GvbsAddr.TimestampControl, 2); // latch — reset 전 값
+ var (_, before) = c.ReadRegs(GvbsAddr.TimestampLatchedHigh, GvbsAddr.TimestampLatchedLow);
+ ulong latchedBefore = ((ulong)before[0] << 32) | before[1];
+ // 판별력의 전제: reset 이 없었다면 아래 래치 값은 적어도 이만큼을 싣는다.
+ Assert.True(latchedBefore >= 200_000_000ul, $"the counter ran only {latchedBefore} ns before the reset");
+
+ long h1 = Stopwatch.GetTimestamp();
+ c.WriteRegOk(GvbsAddr.TimestampControl, 1); // reset
+ c.WriteRegOk(GvbsAddr.TimestampControl, 2); // latch
+ long h2 = Stopwatch.GetTimestamp();
+ var (_, after) = c.ReadRegs(GvbsAddr.TimestampLatchedHigh, GvbsAddr.TimestampLatchedLow);
+ ulong latchedAfter = ((ulong)after[0] << 32) | after[1];
+
+ // 1 µs 여유: 양쪽이 틱을 ns 로 바꾸며 버리는 끝자리.
+ ulong bracketNs = (ulong)((h2 - h1) * (1_000_000_000.0 / Stopwatch.Frequency)) + 1_000;
+ Assert.True(latchedAfter <= bracketNs,
+ $"after a reset the latched count {latchedAfter} ns must fit in the {bracketNs} ns between sending the reset and the latch ACK (before the reset: {latchedBefore} ns)");
+ }
+
+ [Fact]
+ public void RetransmittedCommand_WithTheSameReqId_IsExecutedAgain()
+ {
+ // 응답기는 req_id 를 기억하지 않는다 — 같은 req_id 로 다시 온 명령(호스트가 늦은 ACK 를 기다리다 재전송한 것)도
+ // 새 명령처럼 다시 실행한다. 자기 소거 명령이면 효과가 두 번 난다. 문서(sim-register-map.md)가 적는 이 동작을 래치로 못 박는다:
+ // 두 번째 실행은 더 늦은 카운터를 싣는다.
+ using var dev = StartDevice();
+ using var c = new RawGvcpClient(dev.GvcpEndPoint);
+ const ushort reqId = 0x1234;
+ var latch = RawGvcpClient.BuildCmd(GvcpConst.WriteRegCmd, GvcpConst.FlagAckRequired, reqId,
+ RawGvcpClient.WriteRegPayload((GvbsAddr.TimestampControl, 2)));
+ int writesBefore = dev.WriteRegCount;
+
+ c.SendRaw(latch);
+ var first = c.Receive() ?? throw new TimeoutException("no reply to the first send");
+ Assert.Equal(reqId, first.ReqId);
+ Assert.Equal(GvcpConst.StatusSuccess, first.Status);
+ var (_, a) = c.ReadRegs(GvbsAddr.TimestampLatchedHigh, GvbsAddr.TimestampLatchedLow);
+
+ Thread.Sleep(5);
+ c.SendRaw(latch); // 같은 바이트, 같은 req_id
+ var second = c.Receive() ?? throw new TimeoutException("no reply to the retransmission");
+ Assert.Equal(reqId, second.ReqId);
+ Assert.Equal(GvcpConst.StatusSuccess, second.Status);
+ var (_, b) = c.ReadRegs(GvbsAddr.TimestampLatchedHigh, GvbsAddr.TimestampLatchedLow);
+
+ Assert.Equal(writesBefore + 2, dev.WriteRegCount);
+ Assert.True((((ulong)b[0] << 32) | b[1]) > (((ulong)a[0] << 32) | a[1]), "the retransmitted latch must run again and capture a later count");
+ }
+
[Fact]
public void Ctor_RejectsNonIPv4BindAddress()
{
diff --git a/tests/GevSharp.Tests/Xml/GevXmlLoaderHttpTimeoutTests.cs b/tests/GevSharp.Tests/Xml/GevXmlLoaderHttpTimeoutTests.cs
new file mode 100644
index 0000000..b0d245b
--- /dev/null
+++ b/tests/GevSharp.Tests/Xml/GevXmlLoaderHttpTimeoutTests.cs
@@ -0,0 +1,83 @@
+using System.Net;
+using System.Net.Sockets;
+using GevSharp.Xml;
+
+namespace GevSharp.Tests.Xml;
+
+///
+/// http 내려받기의 시한 초과를 실제 시한()만큼 기다려 밟는다.
+/// 한 번에 10 초가 들어, 다른 적재 시험 뒤에 줄 서지 않고 나란히 돌도록 클래스를 따로 둔다.
+///
+public class GevXmlLoaderHttpTimeoutTests
+{
+ /// 연결은 받아 두고 아무것도 답하지 않는 서버. 받은 연결은 해제할 때까지 쥐고 있는다.
+ private sealed class SilentTcpServer : IDisposable
+ {
+ private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
+ private readonly List _clients = new();
+ private int _accepted;
+
+ public SilentTcpServer()
+ {
+ _listener.Start();
+ BaseUri = new Uri($"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/");
+ _ = AcceptLoopAsync();
+ }
+
+ public Uri BaseUri { get; }
+
+ /// 받아 둔 연결 수 — 시한 초과가 정말로 서버 쪽에서 났는지(연결은 됐는지) 보는 데 쓴다.
+ public int Accepted => Volatile.Read(ref _accepted);
+
+ private async Task AcceptLoopAsync()
+ {
+ while (true)
+ {
+ TcpClient client;
+ try
+ {
+ client = await _listener.AcceptTcpClientAsync();
+ }
+ catch
+ {
+ return;
+ }
+
+ lock (_clients) _clients.Add(client);
+ Interlocked.Increment(ref _accepted);
+ }
+ }
+
+ public void Dispose()
+ {
+ _listener.Stop();
+ lock (_clients)
+ {
+ foreach (var c in _clients) c.Dispose();
+ _clients.Clear();
+ }
+ }
+ }
+
+ [Fact]
+ public async Task AnHttpTimeoutStaysWrappedSoItIsNotReadAsDeviceLoss()
+ {
+ // 감싸지 않은 GevTimeoutException 은 호출자에게 "장치를 잃었다, 다시 연결하라" 는 뜻이다. 서버가 답하지 않은 것은
+ // 장치와 무관하다 — 다시 연결해도 같은 서버에서 같은 시한 초과를 다시 겪는다. 그래서 이 형이 맨몸으로 나가면 안 되고,
+ // 두 URL 의 실패를 모은 GevException 안에 실려야 한다. Second URL 이 First URL 과 같아 시도한 URL 이 하나뿐인 경우가
+ // 그 규칙이 가장 쉽게 새는 자리다(실패가 하나뿐이면 "모두 같은 형" 이 곧바로 참이 된다).
+ using var server = new SilentTcpServer();
+ var port = new FakeMemPort();
+ var url = server.BaseUri + "cam.xml";
+ port.SetFirstUrl(url);
+ port.SetSecondUrl(url);
+
+ var ex = await Assert.ThrowsAsync(() => GevXmlLoader.LoadAsync(port, null, TestContext.Current.CancellationToken));
+
+ var inner = Assert.IsType(ex.InnerException);
+ Assert.Contains("Downloading camera XML", inner.Message);
+ Assert.Equal(true, inner.Data[GevXmlLoader.HttpTimeoutKey]); // 실제로 던지는 자리가 "서버 쪽 시한 초과" 표식을 단다
+ Assert.Contains("identical to the First URL", ex.Message);
+ Assert.Equal(1, server.Accepted); // 연결은 됐다 — 시한 초과는 답하지 않은 서버에서 났다
+ }
+}
diff --git a/tests/GevSharp.Tests/Xml/GevXmlLoaderTests.cs b/tests/GevSharp.Tests/Xml/GevXmlLoaderTests.cs
index 5efdccb..fb06c54 100644
--- a/tests/GevSharp.Tests/Xml/GevXmlLoaderTests.cs
+++ b/tests/GevSharp.Tests/Xml/GevXmlLoaderTests.cs
@@ -2,6 +2,7 @@
using System.Reflection;
using System.Text;
using GevSharp.Gvcp;
+using GevSharp.Tests.Gvcp;
using GevSharp.Xml;
namespace GevSharp.Tests.Xml;
@@ -371,6 +372,215 @@ public async Task BothUrlsEmptyThrows()
Assert.Contains("register is empty", ex.Message);
}
+ // ---- 장치 상실은 감싸지 않는다 ----
+
+ // 두 URL 이 모두 읽을 수 있는 XML 을 가리키는 포트 — 첫 시도가 장치 상실로 끝나면 둘째로 넘어가는지 볼 수 있게.
+ private static FakeMemPort PortWithTwoLocalUrls()
+ {
+ var bytes = Encoding.UTF8.GetBytes("");
+ var port = new FakeMemPort();
+ port.AddRegion(XmlAddr, Pad(bytes, 16));
+ port.AddRegion(XmlAddr + 0x10000, Pad(bytes, 16));
+ port.SetFirstUrl($"Local:first.xml;{XmlAddr:X};{bytes.Length:X}");
+ port.SetSecondUrl($"Local:second.xml;{XmlAddr + 0x10000:X};{bytes.Length:X}");
+ return port;
+ }
+
+ // 채널이 PENDING_ACK 연장을 다 쓴 요청에 내는 것과 같은 모양의 시한 초과 — 장치가 답했다는 표식이 붙어 있다.
+ private static GevTimeoutException PendingAckExpired()
+ {
+ var ex = new GevTimeoutException("READMEM was answered with PENDING_ACK but never completed");
+ ex.Data[GvcpChannel.PendingAckExpiredKey] = true;
+ return ex;
+ }
+
+ [Fact]
+ public async Task ControlLossOnTheFirstUrlRegisterIsRethrownWithoutTryingTheSecond()
+ {
+ // 장치를 잃었으면 Second URL 도 같은 포트를 거친다 — 재시도 예산만 한 번 더 쓰고 같은 이유로 실패한다.
+ // 호출자는 형으로 "다시 연결" 과 "XML 이 틀렸다" 를 가르므로, 제어 상실이 일반 GevException 으로 뭉개지면 안 된다.
+ var port = PortWithTwoLocalUrls();
+ port.OnRead = (addr, _) =>
+ {
+ if (addr == GvbsAddr.FirstUrl) throw new GevControlLostException("heartbeat failed");
+ };
+
+ var ex = await Assert.ThrowsAsync(() => GevXmlLoader.LoadAsync(port, null, Ct));
+
+ Assert.Equal("heartbeat failed", ex.Message);
+ Assert.Equal(0, port.Reads.Count(r => r.Addr == GvbsAddr.SecondUrl));
+ }
+
+ [Fact]
+ public async Task DisposedPortOnTheFirstUrlRegisterIsRethrownAsDisposed()
+ {
+ var port = PortWithTwoLocalUrls();
+ port.OnRead = (addr, _) =>
+ {
+ if (addr == GvbsAddr.FirstUrl) throw new ObjectDisposedException("GevDevice");
+ };
+
+ await Assert.ThrowsAsync(() => GevXmlLoader.LoadAsync(port, null, Ct));
+
+ Assert.Equal(0, port.Reads.Count(r => r.Addr == GvbsAddr.SecondUrl));
+ }
+
+ [Fact]
+ public async Task TimeoutWhileReadingLocalXmlMemoryIsNotWrappedAndSkipsTheSecondUrl()
+ {
+ // 장치 메모리 읽기는 실패를 GevException 으로 감싸 파일 이름을 붙인다 — 장치 상실만은 그 포장 밖으로 그대로 나와야 한다.
+ var port = PortWithTwoLocalUrls();
+ port.OnRead = (addr, _) =>
+ {
+ if (addr >= XmlAddr) throw new GevTimeoutException("READMEM got no reply");
+ };
+
+ var ex = await Assert.ThrowsAsync(() => GevXmlLoader.LoadAsync(port, null, Ct));
+
+ Assert.Equal("READMEM got no reply", ex.Message);
+ Assert.Equal(0, port.Reads.Count(r => r.Addr == GvbsAddr.SecondUrl));
+ Assert.Equal(1, port.ReadCountAtOrAbove(XmlAddr));
+ }
+
+ [Fact]
+ public async Task DeviceLossOnTheSecondAttemptIsRethrownUnwrapped()
+ {
+ // 첫 URL 이 내용 문제로 실패하고 둘째를 읽다가 장치를 잃었다 — 지금 할 일은 다시 연결이다(다시 연결하면 둘째가 될 수 있다).
+ // 첫 실패의 사유는 경고 로그에 남는다.
+ var bytes = Encoding.UTF8.GetBytes("");
+ var port = new FakeMemPort();
+ port.AddRegion(XmlAddr, Pad(bytes, 16));
+ port.SetFirstUrl("Local:broken");
+ port.SetSecondUrl($"Local:second.xml;{XmlAddr:X};{bytes.Length:X}");
+ port.OnRead = (addr, _) =>
+ {
+ if (addr >= XmlAddr) throw new GevControlLostException("control taken over");
+ };
+
+ await Assert.ThrowsAsync(() => GevXmlLoader.LoadAsync(port, null, Ct));
+ }
+
+ [Fact]
+ public async Task SameStatusFailureOnBothUrlRegistersKeepsTheStatusType()
+ {
+ // 두 URL 이 같은 종류로 실패했으면 그 종류가 곧 답이다 — 상태 코드까지 호출자에게 그대로 간다.
+ var port = PortWithTwoLocalUrls();
+ port.OnRead = (addr, _) =>
+ {
+ if (addr == GvbsAddr.FirstUrl || addr == GvbsAddr.SecondUrl)
+ throw new GevStatusException("READMEM", GvcpConst.StatusAccessDenied);
+ };
+
+ var ex = await Assert.ThrowsAsync(() => GevXmlLoader.LoadAsync(port, null, Ct));
+
+ Assert.Equal(GvcpConst.StatusAccessDenied, ex.Status);
+ Assert.Equal(1, port.Reads.Count(r => r.Addr == GvbsAddr.SecondUrl)); // 장치 상실이 아니니 둘째도 시도했다
+ }
+
+ [Fact]
+ public void HttpTimeoutIsNotDeviceLossSoTheOtherUrlIsStillTried()
+ {
+ // http 내려받기의 시한 초과는 서버 쪽 사정이다 — 다른 URL(대개 Local:)로 넘어갈 이유가 남아 있다.
+ // 끝에서 끝까지 밟으려면 내려받기 시한(10 초)을 실제로 기다려야 해서(GevXmlLoaderHttpTimeoutTests 가 한 번 밟는다),
+ // 여기서는 가르는 판정 자체를 본다. 판정은 URL 종류가 아니라 예외에 붙은 표식으로 한다 — http URL 을 적재하는
+ // 중에도 캐시 키는 장치에서 읽으므로, 같은 단계에서 난 시한 초과라도 어느 쪽이 답하지 않았는지는 표식만 안다.
+ var httpTimeout = new GevTimeoutException("Downloading camera XML timed out");
+ httpTimeout.Data[GevXmlLoader.HttpTimeoutKey] = true;
+
+ Assert.False(GevXmlLoader.IsDeviceLoss(httpTimeout));
+ Assert.True(GevXmlLoader.IsDeviceLoss(new GevTimeoutException("no reply"))); // 표식 없는 시한 초과 = 응답 없는 GVCP 요청
+ // 장치가 PENDING_ACK 로 답한 뒤 연장을 다 쓴 시한 초과 — 장치는 살아 있다.
+ Assert.False(GevXmlLoader.IsDeviceLoss(PendingAckExpired()));
+ Assert.True(GevXmlLoader.IsDeviceLoss(new GevControlLostException("lost")));
+ Assert.True(GevXmlLoader.IsDeviceLoss(new ObjectDisposedException("GevDevice")));
+ Assert.False(GevXmlLoader.IsDeviceLoss(new GevStatusException("READMEM", GvcpConst.StatusInvalidAddress)));
+ Assert.False(GevXmlLoader.IsDeviceLoss(new GevException("bad XML")));
+ }
+
+ [Fact]
+ public async Task FailuresOfDifferentKindsAreStillAggregated()
+ {
+ // 대조군: 첫 URL 은 장치 거절, 둘째는 형식 오류 — 종류가 다르면 두 사유를 모두 실은 GevException 이다.
+ var port = new FakeMemPort();
+ port.SetSecondUrl("garbage");
+ port.OnRead = (addr, _) =>
+ {
+ if (addr == GvbsAddr.FirstUrl) throw new GevStatusException("READMEM", GvcpConst.StatusAccessDenied);
+ };
+
+ var ex = await Assert.ThrowsAsync(() => GevXmlLoader.LoadAsync(port, null, Ct));
+
+ Assert.Contains("First URL", ex.Message);
+ Assert.Contains("Second URL", ex.Message);
+ Assert.Contains("garbage", ex.Message);
+ }
+
+ [Fact]
+ public async Task APendingAckThatNeverCompletesOnTheXmlReadFallsBackToTheSecondUrl()
+ {
+ // 장치가 PENDING_ACK 로 "받아서 실행 중" 이라고 답했다 — 살아 있는 장치다. 연장 안에 못 끝낸 읽기는 상실이 아니라
+ // 이 URL 의 실패이므로 Second URL 로 넘어간다(다시 연결해도 같은 First URL 이 같은 자리에서 멈출 뿐이다).
+ // 실제 채널이 던지는 예외를 그대로 받아야 두 시한 초과를 가르는 표식까지 밟으므로 루프백 장치로 연다.
+ var first = Encoding.ASCII.GetBytes("");
+ var second = Encoding.ASCII.GetBytes("");
+ using var r = new GvcpTestResponder();
+ first.CopyTo(r.Memory, 0x8000);
+ second.CopyTo(r.Memory, 0x9000);
+ Encoding.ASCII.GetBytes($"Local:first.xml;8000;{first.Length:X}").CopyTo(r.Memory, (int)GvbsAddr.FirstUrl);
+ Encoding.ASCII.GetBytes($"Local:second.xml;9000;{second.Length:X}").CopyTo(r.Memory, (int)GvbsAddr.SecondUrl);
+ await using var dev = await GevDevice.OpenAsync(r.EndPoint, new GevDeviceOpt
+ {
+ GvcpTimeoutMs = 300,
+ GvcpRetries = 1,
+ MaxPendingAckWaitMs = 200,
+ // 하트비트는 시험 동안 돌지 않게 멀리 둔다 — 멈춘 요청 뒤에 줄을 서서 제어권을 흔들지 않게.
+ HeartbeatTimeoutMs = 120_000,
+ HeartbeatPeriodMs = 60_000,
+ }, Ct);
+ r.PendingAckStallAddr = 0x8000;
+
+ var doc = await dev.GetXmlAsync(Ct);
+
+ Assert.Equal("", doc.Xml);
+ Assert.Equal("second.xml", doc.FileName);
+ Assert.Equal(1, r.CountOfReg(GvcpConst.ReadMemCmd, 0x8000)); // PENDING_ACK 를 받은 읽기는 다시 보내지 않았다
+ Assert.Equal(1, dev.Gvcp.PendingAckCount); // 멈춘 것이 무응답이 아니라 PENDING_ACK 였다
+ }
+
+ [Fact]
+ public async Task APendingAckExpiredUrlRegisterReadFallsBackToTheSecondUrl()
+ {
+ // URL 레지스터 읽기에서도 같다 — 장치가 답했으면 상실이 아니고, 다른 URL 은 끝날 수 있다.
+ var port = PortWithTwoLocalUrls();
+ port.OnRead = (addr, _) =>
+ {
+ if (addr == GvbsAddr.FirstUrl) throw PendingAckExpired();
+ };
+
+ var doc = await GevXmlLoader.LoadAsync(port, null, Ct);
+
+ Assert.Equal("second.xml", doc.FileName);
+ }
+
+ [Fact]
+ public async Task TimeoutsFromALiveDeviceOnBothUrlsStayWrapped()
+ {
+ // 두 URL 이 모두 "장치가 답한" 시한 초과로 실패했다 — 형은 같지만 맨 GevTimeoutException 으로 내면 호출자는
+ // 장치 상실로 읽고 멀쩡한 장치를 다시 연결한다. 모은 GevException 안에 실어야 한다.
+ var port = PortWithTwoLocalUrls();
+ port.OnRead = (addr, _) =>
+ {
+ if (addr == GvbsAddr.FirstUrl || addr == GvbsAddr.SecondUrl) throw PendingAckExpired();
+ };
+
+ var ex = await Assert.ThrowsAsync(() => GevXmlLoader.LoadAsync(port, null, Ct));
+
+ Assert.IsType(ex.InnerException);
+ Assert.Contains("First URL", ex.Message);
+ Assert.Contains("Second URL", ex.Message);
+ Assert.Equal(1, port.Reads.Count(r => r.Addr == GvbsAddr.SecondUrl)); // 상실이 아니니 둘째도 시도했다
+ }
+
// ---- ExtractXml ----
[Fact]
@@ -831,6 +1041,78 @@ public async Task NoCacheDirMeansNothingIsWritten()
Assert.DoesNotContain(port.Reads, r => r.Addr == GvbsAddr.ManufacturerName);
}
+ [Fact]
+ public async Task DeviceLossWhileReadingTheCacheKeyIsRethrownBeforeTheXmlIsRead()
+ {
+ // 캐시 키(GVBS 의 제조사·모델·버전)를 읽다가 장치를 잃었으면 그 자리에서 멈춘다. 삼키고 캐시 없이 넘어가면
+ // XML 영역을 읽느라 재시도 예산을 한 번 더 다 쓰고 나서야 같은 상실을 알게 된다.
+ using var tmp = new TempDir();
+ var port = PortWithTwoLocalUrls();
+ var timeouts = 0;
+ port.OnRead = (addr, _) =>
+ {
+ if (addr == GvbsAddr.ManufacturerName || addr >= XmlAddr)
+ {
+ timeouts++;
+ throw new GevTimeoutException("READMEM got no reply");
+ }
+ };
+
+ var ex = await Assert.ThrowsAsync(() => GevXmlLoader.LoadAsync(port, tmp.Path, Ct));
+
+ Assert.Equal("READMEM got no reply", ex.Message);
+ Assert.Equal(1, timeouts); // 무응답을 한 번만 겪었다 — 재시도 예산을 두 번 쓰지 않았다
+ Assert.Equal(new ulong[] { GvbsAddr.FirstUrl, GvbsAddr.ManufacturerName }, port.Reads.Select(r => r.Addr));
+ Assert.False(Directory.Exists(tmp.Path));
+ }
+
+ [Fact]
+ public async Task DeviceLossWhileReadingTheCacheKeyStopsAnHttpUrlToo()
+ {
+ // http URL 이어도 캐시 키는 장치에서 읽는다 — 거기서 난 무응답은 서버가 아니라 장치의 상실이다.
+ // URL 종류로 시한 초과를 가르면 이 상실이 "서버 쪽 사정" 으로 읽혀 무시되거나 다른 URL 로 넘어간다.
+ using var tmp = new TempDir();
+ var hits = 0;
+ using var server = new LoopbackHttpServer(_ =>
+ {
+ Interlocked.Increment(ref hits);
+ return (200, Encoding.ASCII.GetBytes(""));
+ });
+ var port = new FakeMemPort();
+ port.SetFirstUrl(server.BaseUri + "cam.xml");
+ port.OnRead = (addr, _) =>
+ {
+ if (addr == GvbsAddr.ManufacturerName) throw new GevTimeoutException("READMEM got no reply");
+ };
+
+ await Assert.ThrowsAsync(() => GevXmlLoader.LoadAsync(port, tmp.Path, Ct));
+
+ Assert.Equal(0, Volatile.Read(ref hits));
+ Assert.Equal(0, port.Reads.Count(r => r.Addr == GvbsAddr.SecondUrl));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task ACacheKeyReadFailureFromALiveDeviceStillLoadsWithoutTheCache(bool isPendingAckExpired)
+ {
+ // 대조군: 장치가 살아서 답한 실패(상태 오류, PENDING_ACK 연장 소진)는 상실이 아니다 — 캐시 없이 이어 가는 원래 동작 그대로다.
+ using var tmp = new TempDir();
+ var port = PortWithLocal(Encoding.UTF8.GetBytes(""), "cam.xml");
+ port.OnRead = (addr, _) =>
+ {
+ if (addr != GvbsAddr.ManufacturerName) return;
+ if (isPendingAckExpired) throw PendingAckExpired();
+ throw new GevStatusException("READMEM", GvcpConst.StatusAccessDenied);
+ };
+
+ var doc = await GevXmlLoader.LoadAsync(port, tmp.Path, Ct);
+
+ Assert.Equal("", doc.Xml);
+ Assert.True(port.ReadCountAtOrAbove(XmlAddr) > 0);
+ Assert.False(Directory.Exists(tmp.Path)); // 캐시 키가 없으니 쓰지도 않았다
+ }
+
// ---- File: ----
[Fact]