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}>GateR110" + + "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]