From 396caf7126719259afe80dad80318deb52d681d2 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 10:11:08 +0900 Subject: [PATCH 01/48] =?UTF-8?q?dev=20=EC=9D=98=20=ED=8C=90=20=EB=B2=88?= =?UTF-8?q?=ED=98=B8=EB=A5=BC=200.4.2-dev=20=EB=A1=9C=20=E2=80=94=200.4.1?= =?UTF-8?q?=20=EC=9D=80=20=EB=82=98=EA=B0=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 948061c..0f87d14 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,7 +7,7 @@ 내지 않는다 — 다음 판에 같이 나간다). 올린 번호는 다시 쓰지 않는다 — NuGet 은 지울 수도 덮어쓸 수도 없다. publish.yml 이 태그와 이 값이 같은지, 태그 커밋이 main 에 있는지 검사하고, 태그가 아닌 ref 에서는 올리지 않는다. --> - 0.4.1 + 0.4.2-dev kintaein Apache-2.0 From 9af786b5ee659f9d25d9d1c2b2496bbf7b9b92d8 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 11:36:34 +0900 Subject: [PATCH 02/48] =?UTF-8?q?=EC=A0=95=EC=A7=80=20=EC=88=9C=EA=B0=84?= =?UTF-8?q?=EC=9D=98=20=EB=B8=94=EB=A1=9D=EC=97=90=20=EB=8C=80=ED=95=9C=20?= =?UTF-8?q?=ED=95=98=EB=A5=98=20=EC=8B=A4=EC=B8=A1=EC=9D=84=20evaluation.m?= =?UTF-8?q?d=20=EC=97=90=20=EC=A0=81=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 완성 판정(R29)이 막는 '장치가 블록을 끊는' 경우가 실기에서 나는지는 미관측으로 남아 있었다. 하류(CvInspect 어댑터 경유, 0.4.1 게시 패키지)가 벤치 Basler acA2500-14gm 에서 단발 그랩·라이브·1 ms 시한 그랩(프레임이 오는 도중 정지)·호출 직후 취소를 돌려 84 완성 0 불완전을 보고했다 — 이 기종은 정지할 때 보내던 블록을 끝까지 보낸다. 측정 층(CLI 하네스가 아니라 소비자 어댑터)과 출처를 절 머리에 밝히고, 끊긴 블록은 여전히 루프백 테스트로만 덮인다는 것과 다른 기종은 미측정이라는 범위를 함께 적는다. 문서만. --- docs/evaluation.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/evaluation.md b/docs/evaluation.md index d3a6916..8c1eb36 100644 --- a/docs/evaluation.md +++ b/docs/evaluation.md @@ -273,6 +273,14 @@ 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. On this camera it did not happen: +the consumer ran GevSharp 0.4.1 through its adapter 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 stream stopped with 84 +completed, 0 incomplete, and the late frames were the whole blocks, discarded by the next grab's drain. So this +Basler finishes the block it is sending when stopped; a cut block is still covered only by loopback tests, and +whether other models cut is unmeasured. Figures are the consumer's; see its records for the run. + `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: From 8a230e0973f41e10dd93826a9aa3bee32e73336a Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 12:26:24 +0900 Subject: [PATCH 03/48] =?UTF-8?q?=EC=A0=95=EC=A7=80=20=EC=88=9C=EA=B0=84?= =?UTF-8?q?=EC=9D=98=20=EB=B8=94=EB=A1=9D=20=EC=A0=88=EC=9D=84=20=EC=A0=95?= =?UTF-8?q?=EC=A0=95=ED=95=9C=EB=8B=A4=20=E2=80=94=20=EC=9D=B4=20Basler=20?= =?UTF-8?q?=EB=8A=94=20=EA=B0=84=ED=97=90=EC=A0=81=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B8=94=EB=A1=9D=EC=9D=84=20=EB=81=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞 커밋은 하류의 첫 실행(불완전 0)을 근거로 '이 Basler 는 정지 때 블록을 끝까지 보낸다' 고 적었다. 같은 절차의 둘째 실행에서 취소 반복 중 블록 55 가 431 패킷(리더가 알린 5,038,848 중 3,856,896 바이트)에서 트레일러로 끝나 불완전으로 닫혔다 — R29 가 막는 경우의 첫 실기 관측이고, 0.4.0 이면 이전 프레임 픽셀을 단 채 완성으로 나갔을 장이다. 한 번 잰 0 을 성질로 적은 것을 '간헐적' 으로 고치고, 같은 실행에서 다음 단발 그랩이 한 번 2 초 시한을 넘긴 것(수신기가 다음 블록을 놓친 것인지 장치가 늦게 시작한 것인지 하류가 0.4.0 대조군으로 가르는 중)을 함께 적는다. 수치는 하류 측정. 문서만. --- docs/evaluation.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/evaluation.md b/docs/evaluation.md index 8c1eb36..fd8a5a4 100644 --- a/docs/evaluation.md +++ b/docs/evaluation.md @@ -274,12 +274,17 @@ frame closed as incomplete, with the one-time warning naming both sizes. Not obs 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. On this camera it did not happen: -the consumer ran GevSharp 0.4.1 through its adapter 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 stream stopped with 84 -completed, 0 incomplete, and the late frames were the whole blocks, discarded by the next grab's drain. So this -Basler finishes the block it is sending when stopped; a cut block is still covered only by loopback tests, and -whether other models cut is unmeasured. Figures are the consumer's; see its records for the run. +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); +whether that is the receiver missing the following block or the device starting late after a mid-block stop was +still being separated with a 0.4.0 control at the time of writing. Figures are the consumer's; 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 From d599e0c1b5a8730528660838cd04eecd9b910eaa Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 12:33:13 +0900 Subject: [PATCH 04/48] =?UTF-8?q?=EC=A0=95=EC=A7=80=20=EB=92=A4=20?= =?UTF-8?q?=EA=B7=B8=EB=9E=A9=EC=9D=98=20=EC=8B=9C=ED=95=9C=20=EC=B4=88?= =?UTF-8?q?=EA=B3=BC=EA=B0=80=20=EC=88=98=EC=8B=A0=EA=B8=B0=20=ED=9A=8C?= =?UTF-8?q?=EA=B7=80=EA=B0=80=20=EC=95=84=EB=8B=98=EC=9D=84=20=EB=8C=80?= =?UTF-8?q?=EC=A1=B0=20=EC=8B=A4=EC=B8=A1=EC=9C=BC=EB=A1=9C=20=EB=8B=AB?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 하류가 같은 어댑터 소스·절차(호출 직후 취소 ×20 → 정상 그랩 ×5, 8묶음)로 0.4.1 과 0.4.0 을 대조했다. 두 판 모두 장치가 트레일러를 일찍 보내 블록을 끊었고(재전송 요청 0 — 수신 누락이 아니다), 끊긴 것 중에 취소 묶음 직후 정상 그랩 자신의 장이 있었다. 0.4.1 은 그 장을 불완전으로 닫아 그랩이 시한 초과(그 창에서 패킷 유입, 완성 0, 불완전 1)로 끝났고, 0.4.0 은 끊긴 장을 완성으로 표시해 그 그랩의 답으로 내보냈다(마지막 12~14 패킷 분량이 이전 프레임 바이트). 시한 초과는 같은 장치 동작이 틀린 영상 대신 실패로 보고된 것이다. 원자료 두 파일에서 끊김 줄(0.4.1 Debug 7·경고 1, 0.4.0 Debug 5)을 확인했다. 범위(이 개체 한 대, 취소 직후 그랩, 8묶음 중 2)와 장치가 왜 끊는지는 안 쟀다는 것을 함께 적는다. 수치는 하류 측정. 문서만. --- docs/evaluation.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/evaluation.md b/docs/evaluation.md index fd8a5a4..aefc455 100644 --- a/docs/evaluation.md +++ b/docs/evaluation.md @@ -282,9 +282,18 @@ after 431 payload packets — 3,856,896 of the 5,038,848 bytes the leader announ 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); -whether that is the receiver missing the following block or the device starting late after a mid-block stop was -still being separated with a 0.4.0 control at the time of writing. Figures are the consumer's; see its records. +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 From 0361ad6f0af944fd5d04d4474d7014f8f1b835b5 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 13:57:04 +0900 Subject: [PATCH 05/48] =?UTF-8?q?GVCP=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=ED=9A=9F=EC=88=98=EC=99=80=20PENDING=5FACK=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=83=81=ED=95=9C=EC=9D=98=20int=20=EC=85=88?= =?UTF-8?q?=EC=9D=B4=20=EA=B0=90=EA=B8=B0=EC=A7=80=20=EC=95=8A=EA=B2=8C=20?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GvcpChannelOpt.Retries(GevDeviceOpt.GvcpRetries) 를 "끝없이 재시도" 로 흔히 고르는 int.MaxValue 로 주면 총 시도 횟수 1 + Retries 가 int 로 음수로 감겨, 요청 루프가 한 번도 돌지 않고 장치에 아무것도 보내지 않은 채 "-2147483648 attempt(s)" 시한 초과로 끝났다. 시도 횟수와 루프 변수를 long 으로 센다. 같은 부류로 AutoPendingAckWaitMs 의 2 × 응답 창이 GvcpTimeoutMs > int.MaxValue / 2 에서 음수로 감겨, 여유가 없는 설정이 경고 없이 수십억 ms 의 PENDING_ACK 상한을 얻었다 (3000/1000/1_100_000_000 → 2094969296). long 으로 셈해 응답 창 하나로 떨어지고 경고가 난다. 상한 검증은 넣지 않는다 — 다른 시간·횟수 옵션도 하한만 검증한다. 두 경우 모두 고치기 전 코드에서 실패하는 시험을 먼저 두고 확인했다. --- src/GevSharp/GevDevice.cs | 6 +++-- src/GevSharp/Gvcp/GvcpChannel.cs | 6 +++-- .../GevSharp.Tests/Gvcp/GevDeviceLogTests.cs | 26 +++++++++++++++++++ tests/GevSharp.Tests/Gvcp/GvcpChannelTests.cs | 15 +++++++++++ 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/GevSharp/GevDevice.cs b/src/GevSharp/GevDevice.cs index 9b138ed..96a064d 100644 --- a/src/GevSharp/GevDevice.cs +++ b/src/GevSharp/GevDevice.cs @@ -118,8 +118,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; } diff --git a/src/GevSharp/Gvcp/GvcpChannel.cs b/src/GevSharp/Gvcp/GvcpChannel.cs index 9ed778c..cd5b564 100644 --- a/src/GevSharp/Gvcp/GvcpChannel.cs +++ b/src/GevSharp/Gvcp/GvcpChannel.cs @@ -155,9 +155,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); 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/GvcpChannelTests.cs b/tests/GevSharp.Tests/Gvcp/GvcpChannelTests.cs index e5c2449..2c1dd21 100644 --- a/tests/GevSharp.Tests/Gvcp/GvcpChannelTests.cs +++ b/tests/GevSharp.Tests/Gvcp/GvcpChannelTests.cs @@ -206,6 +206,21 @@ public async Task SilentDeviceTimesOutAfterFirstSendPlusRetries() Assert.Equal(3, r.CountOf(GvcpConst.ReadRegCmd)); } + [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] public async Task CancellationAbortsTheWaitAndReleasesTheChannel() { From c194d48343509059e7825245e23bf4c9a4bfd4c9 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:00:36 +0900 Subject: [PATCH 06/48] =?UTF-8?q?DeviceHeartbeatTimeoutMs=20=EB=A5=BC=20?= =?UTF-8?q?=EC=9D=8C=EC=88=98=EB=A1=9C=20=EB=82=B4=EB=B3=B4=EB=82=B4?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=ED=8F=AC=ED=99=94=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GVBS 0x0938 은 부호 없는 32비트인데 공개 속성 DeviceHeartbeatTimeoutMs 는 (int) 캐스트라, 2^31 이상의 되읽기가 음수가 됐다. 0xFFFFFFFF 는 -1 = Timeout.Infinite 라 그 값을 대기 시간으로 쓰는 호출자는 영영 기다리고, 제어권 상실 문구도 "device timeout -1 ms" 를 실었다. int 에 들어가지 않는 값은 int.MaxValue 로 포화한다. 소비자를 하나씩 확인했다. 하트비트 주기와 PENDING_ACK 자동 상한은 지금까지 그 범위의 값에서 (음수가 되어) 요청한 타임아웃으로 계산해 왔고, 그 결과는 바꾸지 않는다 — 판정을 포화된 속성이 아니라 원시 레지스터 값(1..int.MaxValue 만 근거로 씀)으로 한다. 포화된 값으로 끌어내면 하트비트가 8 일에 한 번이 되는데 그 되읽기를 믿을 근거가 없고, 너무 드물게 치는 쪽이 잃는 것은 제어권이다. 제어권 상실·하트비트 실패 문구는 포화된 값을 그대로 싣는다. 시험 응답기에 쓰기를 받고도 값을 바꾸지 않는 주소(WriteIgnoredAddr)를 더해 제어 세션에서도 재현한다. 고치기 전 코드에서 속성은 -1, 문구는 "(device timeout -1 ms)" 로 실패하는 것을 확인했고, 주기 1000 ms·상한 1400 ms 는 고치기 전후가 같다. --- docs/architecture.md | 4 +- src/GevSharp/GevDevice.cs | 21 ++++++++-- src/GevSharp/GevDeviceOpt.cs | 5 ++- tests/GevSharp.Tests/Gvcp/GevDeviceTests.cs | 40 +++++++++++++++++++ .../GevSharp.Tests/Gvcp/GvcpTestResponder.cs | 11 ++++- 5 files changed, 73 insertions(+), 8 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 6b74200..84c6e4b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -158,7 +158,7 @@ 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 @@ -178,7 +178,7 @@ public sealed class GevDevice : IGevPort, IAsyncDisposable public bool IsOpen { get; } 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 diff --git a/src/GevSharp/GevDevice.cs b/src/GevSharp/GevDevice.cs index 96a064d..a091452 100644 --- a/src/GevSharp/GevDevice.cs +++ b/src/GevSharp/GevDevice.cs @@ -70,7 +70,10 @@ private GevDevice(IPEndPoint device, IPAddress localAddress, GevDeviceOpt opt) 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; } @@ -153,7 +156,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)"); @@ -189,9 +192,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"); @@ -203,6 +210,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 diff --git a/src/GevSharp/GevDeviceOpt.cs b/src/GevSharp/GevDeviceOpt.cs index 5d80601..3698d11 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 줄을 붙드는 시간이 여기서 정해진다. diff --git a/tests/GevSharp.Tests/Gvcp/GevDeviceTests.cs b/tests/GevSharp.Tests/Gvcp/GevDeviceTests.cs index f057a4a..cf77301 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() { diff --git a/tests/GevSharp.Tests/Gvcp/GvcpTestResponder.cs b/tests/GevSharp.Tests/Gvcp/GvcpTestResponder.cs index d141575..4fbacbc 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 @@ -56,6 +56,7 @@ public GvcpTestResponder() 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; } @@ -94,6 +95,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); @@ -295,6 +302,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) From 7c05158e62503aee547de1433c50b031ff611a86 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:03:17 +0900 Subject: [PATCH 07/48] =?UTF-8?q?MaxPendingAckWaitMs=20=3D=200=20=EC=9D=98?= =?UTF-8?q?=20=EB=9C=BB=EC=9D=84=20=EC=98=B5=EC=85=98=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EC=97=90=20=EC=A0=81=EA=B3=A0=20=EC=8B=9C=ED=97=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=AA=BB=20=EB=B0=95=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GevDeviceOpt.MaxPendingAckWaitMs 는 0 을 받아들이면서 그 뜻을 적지 않아 "상한 없음" 이나 "PENDING_ACK 끄기" 로 읽힐 수 있었다. 실제 뜻은 연장이 0 이라는 것이다 — 장치가 PENDING_ACK 로 답한 명령은 응답 창(GvcpTimeoutMs) 하나 안에 끝나야 하고, 못 끝나면 GvcpRetries 와 무관하게 다시 보내지 않고 GevTimeoutException 으로 끝난다. 응답 창 안에 온 본 응답은 그대로 받는다. GevDeviceOpt·GvcpChannelOpt 의 문서와 architecture.md 의 옵션 설명에 이것을 적는다. 동작은 바꾸지 않는다. 뜻은 코드를 읽는 데서 그치지 않고 루프백 응답기로 실행해 확인했고 그 시험을 남긴다: 0 을 명시하면 자동 계산으로 바뀌지 않고, PENDING_ACK 뒤 곧바로 온 본 응답은 성공하며, PENDING_ACK 만 보내고 멈춘 명령은 재시도 3 에서도 한 번만 보내고 시한 초과로 끝난다. 같은 설정의 무응답 명령이 네 번 보내지는 대조군을 같은 시험에 두어 "한 번" 이 재시도가 꺼진 탓이 아님을 가른다. "0 = 상한 없음" 회귀(예고된 60 s 대기)는 넉넉한 시간 상한으로 잡는다. --- docs/architecture.md | 4 ++- src/GevSharp/GevDeviceOpt.cs | 4 +++ src/GevSharp/Gvcp/GvcpChannel.cs | 1 + tests/GevSharp.Tests/Gvcp/GevDeviceTests.cs | 40 +++++++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 84c6e4b..4dd8761 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -163,7 +163,9 @@ public sealed class GevDeviceOpt 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 diff --git a/src/GevSharp/GevDeviceOpt.cs b/src/GevSharp/GevDeviceOpt.cs index 3698d11..5b10dc0 100644 --- a/src/GevSharp/GevDeviceOpt.cs +++ b/src/GevSharp/GevDeviceOpt.cs @@ -34,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/Gvcp/GvcpChannel.cs b/src/GevSharp/Gvcp/GvcpChannel.cs index cd5b564..083eac4 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; } diff --git a/tests/GevSharp.Tests/Gvcp/GevDeviceTests.cs b/tests/GevSharp.Tests/Gvcp/GevDeviceTests.cs index cf77301..fd01998 100644 --- a/tests/GevSharp.Tests/Gvcp/GevDeviceTests.cs +++ b/tests/GevSharp.Tests/Gvcp/GevDeviceTests.cs @@ -365,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() { From e30f465fdaf7cc01291fd749cdc3a9a9c560ef61 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:04:39 +0900 Subject: [PATCH 08/48] =?UTF-8?q?GevTimeoutException=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EC=97=90=20=EC=9D=B4=20=EC=98=88=EC=99=B8=EA=B0=80=20=EB=82=98?= =?UTF-8?q?=EB=8A=94=20=EC=84=B8=20=EA=B2=BD=EC=9A=B0=EB=A5=BC=20=EB=AA=A8?= =?UTF-8?q?=EB=91=90=20=EC=A0=81=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 형의 문서는 "GVCP 요청이 재시도까지 전부 응답 없이 끝났다" 한 가지만 약속했지만, 실제로는 세 자리에서 난다. GVCP 무응답(재전송까지 전부) 말고도, 장치가 PENDING_ACK 로 받아서 실행 중이라고 답한 뒤 허락된 연장 안에 끝내지 못했을 때(GvcpChannel.RequestAsync — 재전송하지 않는다), 그리고 카메라 XML 의 HTTP 다운로드가 HttpTimeoutMs 를 넘겼을 때(GevXmlLoader, GVCP 와 무관)다. 둘째 경우는 장치가 명령을 이미 실행했을 수 있어 "시한 초과 = 닿지 않았으니 다시 보내도 된다" 로 다루는 호출자가 명령을 두 번 실행할 수 있다. 그 차이를 형 문서에 적고, HTTP 경우는 URL 폴백을 도는 적재에서 GevException 의 InnerException 으로 올 수 있다는 것, System.TimeoutException 이 아니라 GevException 에서 파생한다는 것도 적는다. architecture.md 의 RequestAsync 설명에도 PENDING_ACK 뒤에는 재전송하지 않는다는 예외를 더한다. 동작은 바꾸지 않는다. --- docs/architecture.md | 3 ++- src/GevSharp/GevException.cs | 14 +++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 4dd8761..536ed44 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -239,7 +239,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); diff --git a/src/GevSharp/GevException.cs b/src/GevSharp/GevException.cs index 1ea0a15..40f8a5a 100644 --- a/src/GevSharp/GevException.cs +++ b/src/GevSharp/GevException.cs @@ -7,7 +7,19 @@ public GevException(string message) : base(message) { } public GevException(string message, Exception? inner) : base(message, inner) { } } -/// GVCP 요청이 재시도까지 전부 응답 없이 끝났다. +/// +/// 기다리던 응답이 시한 안에 오지 않았다. 세 경우에 나고, 다시 시도해도 되는지가 경우마다 다르다(메시지가 어느 경우인지 밝힌다). +/// +/// GVCP 요청이 재전송까지 전부 응답 없이 끝났다. 장치가 명령을 받았는지는 알 수 없다. +/// 장치가 PENDING_ACK 로 "받아서 실행 중" 이라고 답한 뒤 허락된 연장(, +/// ) 안에 끝내지 못했다. 명령이 이미 장치에 있으므로 라이브러리는 재전송하지 않는다 — +/// 장치가 그 명령을 실행했을 수 있으니, 호출자가 같은 명령을 다시 보내면 두 번 실행될 수 있다. +/// 카메라 XML 을 HTTP 로 받다가 를 넘겼다(GVCP 와 무관). GevXmlLoader.LoadFromUrlAsync 는 +/// 이 예외를 그대로 던지고, First/Second URL 을 차례로 시도하는 GevXmlLoader.LoadAsync· 에서는 +/// 두 URL 의 실패를 모은 의 으로 실려 올 수 있다. +/// +/// 에서 파생한다 — 이 아니므로 catch (TimeoutException) 에는 걸리지 않는다. +/// public sealed class GevTimeoutException : GevException { public GevTimeoutException(string message) : base(message) { } From 241f1feb9684582a7ee8a16319bc42aabc310455 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:07:27 +0900 Subject: [PATCH 09/48] =?UTF-8?q?IGevPort=20=EC=9D=98=2032=EB=B9=84?= =?UTF-8?q?=ED=8A=B8=20=EC=B4=88=EA=B3=BC=20=EC=A3=BC=EC=86=8C=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20=EB=8F=99=EC=9E=91(?= =?UTF-8?q?=ED=95=98=EC=9C=84=2032=EB=B9=84=ED=8A=B8=EB=A1=9C=20=EC=A2=81?= =?UTF-8?q?=ED=9E=98)=EC=97=90=20=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GevDevice 의 IGevPort.ReadAsync 문서는 "32비트를 넘는 주소는 GevException" 이라고 했지만, 구현은 하위 32비트로 좁히고 주소마다 경고를 한 번 남긴 뒤 접근하며, 좁힌 범위의 끝이 32비트 공간을 넘을 때만 던진다. 어느 쪽이 맞는지는 이미 정해져 있다 — 벤더 XML 이 32비트를 넘는 주소를 리터럴로 적고 (파일 접근 기준주소 0xFFFFD0000000) 실장치에서 하위 32비트(0xD0000000)가 유효한 레지스터임을 확인했으며, protocol-notes.md·architecture.md 와 기존 시험이 그 동작을 적고 있다. 좁히기는 경고를 남기므로 조용하지 않다. 그래서 동작은 두고 메서드 문서와 IGevPort 인터페이스의 모호한 문장("범위를 벗어나면 예외")을 고친다. 기존 시험은 좁히기의 성공 경로와 좁은 주소의 끝 넘침만 덮고 있어, 넓은 주소가 좁혀진 뒤 끝이 넘치는 경우(0xFFFFFFFFFFFFFFFE+4 등)도 아무것도 보내기 전에 거절되는지 시험으로 못 박는다. --- src/GevSharp/GevDevice.Access.cs | 8 ++++++-- src/GevSharp/IGevPort.cs | 3 ++- tests/GevSharp.Tests/Gvcp/GevDeviceTests.cs | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) 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/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/tests/GevSharp.Tests/Gvcp/GevDeviceTests.cs b/tests/GevSharp.Tests/Gvcp/GevDeviceTests.cs index fd01998..0e946f5 100644 --- a/tests/GevSharp.Tests/Gvcp/GevDeviceTests.cs +++ b/tests/GevSharp.Tests/Gvcp/GevDeviceTests.cs @@ -871,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)); + } } From 88c71e2aae0f5e0986d3d6df6b5d5d117cad310e Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:01:43 +0900 Subject: [PATCH 10/48] =?UTF-8?q?=ED=83=90=EC=83=89=20=EC=86=8C=EC=BC=93?= =?UTF-8?q?=EC=9D=98=20=EB=B0=94=EC=9D=B8=EB=93=9C=EA=B0=80=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=ED=95=98=EB=A9=B4=20=EA=B7=B8=20=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=8B=AB=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인터페이스마다 만드는 탐색 소켓은 using 블록 밖에서 만들어졌다. 바인드나 옵션 설정이 SocketException 으로 실패하면 경고만 남기고 돌아와, 소켓 핸들이 GC 종료자가 돌 때까지 남았다. 탐색을 부를 때마다 묶이지 않는 인터페이스(사라진 주소·터널 어댑터 등) 수만큼 쌓인다. 실패한 소켓을 catch 에서 닫고, SocketException 이 아닌 예외로 빠져나갈 때도 닫은 뒤 다시 던진다. 회귀: 묶일 수 없는 주소(192.0.2.1)를 인터페이스로 500 번 준 탐색 한 번 뒤 열린 핸들 수를 GC 를 막은 채 잰다. 고치기 전 +533(전체 GC 뒤 +35 로 내려가 차이가 버려진 소켓임을 보인다), 고친 뒤 +33. 브로드캐스트를 끄고 루프백의 닫힌 포트만 대상으로 두어 호스트 밖으로 나가는 것은 없다. 핸들 수는 프로세스 전역이라 격리 컬렉션에서 돈다. --- src/GevSharp/Gvcp/GevDiscovery.cs | 10 +- .../Gvcp/DiscoveryIsolatedTests.cs | 93 +++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 tests/GevSharp.Tests/Gvcp/DiscoveryIsolatedTests.cs diff --git a/src/GevSharp/Gvcp/GevDiscovery.cs b/src/GevSharp/Gvcp/GevDiscovery.cs index cf7a232..704cded 100644 --- a/src/GevSharp/Gvcp/GevDiscovery.cs +++ b/src/GevSharp/Gvcp/GevDiscovery.cs @@ -103,7 +103,9 @@ private static async Task> DiscoverOnInterfaceAsync(GevNet.I return found; } - UdpClient client; + // 소켓은 만드는 순간 OS 핸들을 쥔다 — 바인드·옵션 설정이 실패해도 여기서 닫지 않으면 핸들이 GC 종료자가 돌 때까지 남아, + // 탐색을 부를 때마다 실패한 인터페이스 수만큼 쌓인다. + UdpClient? client = null; try { client = new UdpClient(AddressFamily.InterNetwork); @@ -114,9 +116,15 @@ 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; } + catch + { + client?.Dispose(); + throw; + } using (client) { diff --git a/tests/GevSharp.Tests/Gvcp/DiscoveryIsolatedTests.cs b/tests/GevSharp.Tests/Gvcp/DiscoveryIsolatedTests.cs new file mode 100644 index 0000000..6cf2f16 --- /dev/null +++ b/tests/GevSharp.Tests/Gvcp/DiscoveryIsolatedTests.cs @@ -0,0 +1,93 @@ +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; + } + + [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})"); + } +} From 91f6c2efb84ba005cdb666b88f9f9352f362b111 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:04:16 +0900 Subject: [PATCH 11/48] =?UTF-8?q?=ED=83=90=EC=83=89=20=EB=B0=98=EB=B3=B5?= =?UTF-8?q?=EC=9D=84=20=EC=B0=BD=20=EC=95=88=EC=97=90=20=EA=B0=80=EB=91=90?= =?UTF-8?q?=EA=B3=A0=201=20=EB=AF=B8=EB=A7=8C=EC=9D=98=20Repeat=20?= =?UTF-8?q?=EB=A5=BC=20=EA=B1=B0=EC=A0=88=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GevDiscoveryOpt.Repeat 는 "창 안에서 보내는 총 횟수" 라고 적혀 있었지만 전송 루프는 창을 보지 않았다. 간격이 min(200, TimeoutMs / Repeat) 을 1 ms 로 받친 값이라 Repeat 가 TimeoutMs 보다 크면 루프만으로 (Repeat-1) 번의 대기를 돌았고, 1 ms 대기도 Windows 기본 타이머에서는 약 15 ms 씩 걸려 창을 몇 배로 넘겼다 (Repeat 가 아주 크면 사실상 끝나지 않는다). 또 Repeat 0 이하는 검사 없이 1 로 바뀌어 설정 오류가 숨었다 — 같은 옵션의 TimeoutMs 는 0 이하를 거절한다. - Repeat < 1 이면 TimeoutMs 와 같은 모양으로 ArgumentOutOfRangeException(nameof(opt)). - r 번째 전송을 "창이 열린 시각 + r × 간격" 에 예약하고, 예약 시각이 창 끝 이후인 전송은 보내지 않는다. Repeat <= TimeoutMs 이면 (Repeat-1) × 간격 < 창 이라 지금처럼 Repeat 번을 모두 보내고, 그보다 크면 창 길이(ms)만큼만 보낸다. 건너뛸지는 실제로 깬 시각이 아니라 예약 시각으로 가른다 — 스케줄러가 늦게 깨웠다고 창 안에 예약된 전송을 빠뜨리면 보내는 횟수가 부하에 따라 흔들린다. 덜 보낸 경우는 Debug 한 줄. - Repeat 의 XML 문서와 architecture.md 의 해당 줄에 실제 일정을 적었다. 회귀: Repeat 0·-1 이 ArgumentOutOfRangeException 인지(고치기 전 "No exception was thrown"), 창 100 ms 에 Repeat 1,000,000 을 주고 15 s 가드 토큰을 건 탐색이 창 안에서 끝나고 대상마다 보낸 횟수가 1..100 인지(고치기 전에는 가드가 끊어 TaskCanceledException). --- docs/architecture.md | 4 +- src/GevSharp/Gvcp/GevDiscovery.cs | 37 ++++++++++++++++--- .../GevSharp.Tests/Gvcp/GevDiscoveryTests.cs | 32 ++++++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 536ed44..0217403 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 diff --git a/src/GevSharp/Gvcp/GevDiscovery.cs b/src/GevSharp/Gvcp/GevDiscovery.cs index 704cded..380a0e9 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; } @@ -43,7 +51,8 @@ public static async Task> DiscoverAsync(GevDiscover { 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); if (ifaces.Count == 0) @@ -130,11 +139,28 @@ private static async Task> DiscoverOnInterfaceAsync(GevNet.I { 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 @@ -146,11 +172,12 @@ private static async Task> DiscoverOnInterfaceAsync(GevNet.I 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); } diff --git a/tests/GevSharp.Tests/Gvcp/GevDiscoveryTests.cs b/tests/GevSharp.Tests/Gvcp/GevDiscoveryTests.cs index e9de66e..491c58f 100644 --- a/tests/GevSharp.Tests/Gvcp/GevDiscoveryTests.cs +++ b/tests/GevSharp.Tests/Gvcp/GevDiscoveryTests.cs @@ -270,6 +270,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() { From 986949d7feef1558de36cd4f8bd1ce625d2e07da Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:08:36 +0900 Subject: [PATCH 12/48] =?UTF-8?q?=ED=83=90=EC=83=89=EC=9D=B4=20=EC=95=84?= =?UTF-8?q?=EB=AC=B4=EA=B2=83=EB=8F=84=20=EB=B3=B4=EB=82=B4=EC=A7=80=20?= =?UTF-8?q?=EB=AA=BB=ED=95=9C=20=EB=B9=88=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20?= =?UTF-8?q?=EA=B9=8C=EB=8B=AD=EA=B3=BC=20=ED=95=A8=EA=BB=98=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=A0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DiscoverAsync 의 빈 목록은 "창 동안 아무도 답하지 않았다" 와 모양이 같지만 까닭이 더 있다. - 보낼 인터페이스가 없으면 창을 열지 않고 곧바로 돌아온다. 그 까닭은 셋 — Interfaces 가 빈 목록, 루프백 말고 동작 중(Up)인 IPv4 인터페이스가 없음(케이블이 빠지거나 꺼진 카메라 NIC 이 여기 해당), 인터페이스 목록 조회 실패 — 인데 경고는 "no usable IPv4 interface for discovery" 한 줄이라 어느 것인지 알 수 없었다. 이제 경고가 까닭을 밝히고 창을 기다리지 않았다는 것도 적는다. 조회 실패와 "해당 없음" 을 가르려고 GevNet.GetIpv4Interfaces 에 조회 성공 여부를 돌려주는 internal 오버로드를 더했다. - 인터페이스는 있었지만 어느 것에서도 DISCOVERY_CMD 가 나가지 못하면(바인드 실패·대상 없음·전송 실패) 인터페이스마다 경고한 뒤 요약은 Info "discovery finished ... on N interface(s)" 였다 — 탐색이 돈 것처럼 읽힌다. 인터페이스마다 실제로 나간 전송 수를 세어, 하나도 없으면 요약을 "no DISCOVERY_CMD was sent" 경고로 바꾸고, 나간 경우의 Info 는 "on 보낸 수 of 전체 interface(s)" 로 적는다. - 반환 형식은 그대로다(빈 목록, 예외 아님) — CLI·Viewer·IpConfig 샘플이 빈 목록을 "찾은 장치 없음" 으로 다룬다. DiscoverAsync 문서에 빈 목록의 까닭들과 던지는 예외를 적었고 architecture.md 에도 한 단락 적었다. - ForceIpAsync 는 같은 상황에서 이미 GevException 을 던진다. 그 메시지에도 같은 까닭을 붙였다. 회귀(전역 로그 싱크를 쓰므로 격리 컬렉션): Interfaces 를 빈 목록으로 준 탐색이 까닭을 밝힌 경고 하나만 남기는지(고치기 전 "no usable IPv4 interface for discovery" 에 까닭 없음), 묶일 수 없는 주소 두 개로 준 탐색의 요약이 "no DISCOVERY_CMD was sent" 경고인지(고치기 전 요약은 Info "discovery finished"). --- docs/architecture.md | 5 ++ src/GevSharp/Gvcp/GevDiscovery.cs | 77 +++++++++++++++---- src/GevSharp/Gvcp/GevNet.cs | 10 ++- .../Gvcp/DiscoveryBroadcastTests.cs | 4 +- .../Gvcp/DiscoveryIsolatedTests.cs | 57 +++++++++++++- .../GevSharp.Tests/Gvcp/GevDiscoveryTests.cs | 3 +- 6 files changed, 136 insertions(+), 20 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 0217403..afbefa4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -148,6 +148,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 diff --git a/src/GevSharp/Gvcp/GevDiscovery.cs b/src/GevSharp/Gvcp/GevDiscovery.cs index 380a0e9..6db5fc0 100644 --- a/src/GevSharp/Gvcp/GevDiscovery.cs +++ b/src/GevSharp/Gvcp/GevDiscovery.cs @@ -46,7 +46,24 @@ 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(); @@ -54,25 +71,41 @@ public static async Task> DiscoverAsync(GevDiscover 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; } @@ -102,14 +135,16 @@ 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); } // 소켓은 만드는 순간 OS 핸들을 쥔다 — 바인드·옵션 설정이 실패해도 여기서 닫지 않으면 핸들이 GC 종료자가 돌 때까지 남아, @@ -127,7 +162,7 @@ private static async Task> DiscoverOnInterfaceAsync(GevNet.I { client?.Dispose(); GevLog.Warn(LogSrc, $"{iface}: cannot bind a discovery socket ({ex.SocketErrorCode})", ex); - return found; + return (found, sent); } catch { @@ -166,6 +201,7 @@ private static async Task> DiscoverOnInterfaceAsync(GevNet.I try { await client.SendAsync(packet, packet.Length, target).ConfigureAwait(false); + sent++; } catch (SocketException ex) { @@ -188,7 +224,7 @@ private static async Task> DiscoverOnInterfaceAsync(GevNet.I await receiveTask.ConfigureAwait(false); } } - return found; + return (found, sent); } /// @@ -350,9 +386,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) @@ -400,12 +436,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/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 index 6cf2f16..a09bcf0 100644 --- a/tests/GevSharp.Tests/Gvcp/DiscoveryIsolatedTests.cs +++ b/tests/GevSharp.Tests/Gvcp/DiscoveryIsolatedTests.cs @@ -9,8 +9,9 @@ namespace GevSharp.Tests.Gvcp; /// -/// 프로세스 전역(열린 핸들 수)을 재는 탐색 테스트 — 다른 테스트가 나란히 돌며 소켓·스레드를 여닫으면 수가 흔들리므로 -/// 다른 어떤 컬렉션과도 나란히 돌지 않는 격리 컬렉션에 둔다. 여기 있는 탐색은 전부 창을 열기 전에 끝나는 길만 탄다. +/// 프로세스 전역(로그 싱크·열린 핸들 수)을 보는 탐색 테스트 — 다른 테스트가 나란히 돌면 로그가 섞이고 핸들 수가 흔들리므로 +/// 다른 어떤 컬렉션과도 나란히 돌지 않는 격리 컬렉션에 둔다. 여기 있는 탐색은 전부 창을 열기 전에 끝나는 길만 탄다 — +/// 싱크를 쥔 채 네트워크를 기다리지 않는다. /// [Collection(GevLogSinkCollection.Name)] public class DiscoveryIsolatedTests @@ -42,6 +43,58 @@ public class DiscoveryIsolatedTests 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() { diff --git a/tests/GevSharp.Tests/Gvcp/GevDiscoveryTests.cs b/tests/GevSharp.Tests/Gvcp/GevDiscoveryTests.cs index 491c58f..6780e33 100644 --- a/tests/GevSharp.Tests/Gvcp/GevDiscoveryTests.cs +++ b/tests/GevSharp.Tests/Gvcp/GevDiscoveryTests.cs @@ -318,7 +318,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 } })); // 루프백 인터페이스로는 브로드캐스트가 막힐 수 있다 — 보내졌거나 "보낼 길이 없다"로 끝나야 하고, 어느 쪽이든 멈추지 않는다. From 39800e13aeb305f1a52b54c1190976582cba4075 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:12:49 +0900 Subject: [PATCH 13/48] =?UTF-8?q?ProbeAsync=20=EA=B0=80=20null=20=EC=9D=84?= =?UTF-8?q?=20=EB=8F=8C=EB=A0=A4=EC=A3=BC=EB=8A=94=20=EA=B9=8C=EB=8B=AD?= =?UTF-8?q?=EA=B3=BC=20=EB=8D=98=EC=A7=80=EB=8A=94=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=AC=B8=EC=84=9C=EC=97=90=20=EB=AA=A8=EB=91=90=20?= =?UTF-8?q?=EC=A0=81=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProbeAsync 문서는 "응답이 없으면 null" 한 줄이었지만 코드는 세 경우에 null 을 돌려준다 — 시간 안에 응답이 없을 때(닫힌 포트·다른 명령의 ack·PENDING_ACK 뒤 미완료 포함), 장치가 오류 status 로 답했을 때, 응답이 탐색 블록(248 바이트)보다 짧을 때. 둘째·셋째는 장치가 거기 있는데도 호출자가 "없음" 으로 읽는다. 반환 계약은 바꾸지 않는다. 브로드캐스트 탐색도 같은 응답(오류 status·짧은 응답)을 경고만 남기고 목록에서 건너뛰므로, 그 탐색을 주소 하나에 보내는 프로브가 같은 응답을 같게 다루는 것이 일관되다. 대신 - 공개 오버로드 문서에 null 의 세 까닭과 로그 레벨, 던지는 예외(인자 오류·취소·GevException 으로 감싼 소켓 실패, 프로브 소켓 생성·바인드 실패만은 SocketException 그대로)를 적었다. architecture.md 의 해당 줄도. - 세 로그 줄이 "returning null" 로 끝나 API 결과와 이어지게 했고, 오류 status 경고는 장치가 거기 있다는 것을 밝힌다. 시간 초과 Debug 는 채널 메시지를 실어 무응답과 PENDING_ACK 미완료를 가른다. - CLI 의 discover --probe 는 null 을 "no reply from ..." 으로 찍어 오류 status·짧은 응답도 무응답으로 보였다. "no usable discovery reply ..." 로 바꾸고 까닭은 위에 찍힌 경고에 있다고 적었다(해당 CLI 테스트 문구도). 회귀로 못 박기: 오류 status 로 답하는 장치에 대한 프로브가 예외 없이 null 이고, 예산(10 s)을 다 쓰기 전에 온 응답으로 끝나는지 — 테스트 응답기에 DISCOVERY_CMD 를 오류 status 로 답하는 설정을 더했다. 이 경우는 지금까지 시험이 없었다(동작 변경이 아니라 문서화한 계약을 고정하는 테스트다). --- docs/architecture.md | 4 ++- samples/GevSharp.Cli/Commands/DiscoverCmd.cs | 5 +-- samples/GevSharp.Cli/Tests/CliRunTests.cs | 2 +- src/GevSharp/Gvcp/GevDiscovery.cs | 34 +++++++++++++++---- .../GevSharp.Tests/Gvcp/GevDiscoveryTests.cs | 17 ++++++++++ .../GevSharp.Tests/Gvcp/GvcpTestResponder.cs | 5 +++ 6 files changed, 57 insertions(+), 10 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index afbefa4..ab18647 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -116,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); } 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/Gvcp/GevDiscovery.cs b/src/GevSharp/Gvcp/GevDiscovery.cs index 6db5fc0..f52d810 100644 --- a/src/GevSharp/Gvcp/GevDiscovery.cs +++ b/src/GevSharp/Gvcp/GevDiscovery.cs @@ -338,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)); @@ -355,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); diff --git a/tests/GevSharp.Tests/Gvcp/GevDiscoveryTests.cs b/tests/GevSharp.Tests/Gvcp/GevDiscoveryTests.cs index 6780e33..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() { diff --git a/tests/GevSharp.Tests/Gvcp/GvcpTestResponder.cs b/tests/GevSharp.Tests/Gvcp/GvcpTestResponder.cs index 4fbacbc..381ad49 100644 --- a/tests/GevSharp.Tests/Gvcp/GvcpTestResponder.cs +++ b/tests/GevSharp.Tests/Gvcp/GvcpTestResponder.cs @@ -50,6 +50,7 @@ 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; @@ -76,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 { @@ -269,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)); From a1702fa2d9ed576c9c88fcbcc57002a6dd7f7a70 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:03:53 +0900 Subject: [PATCH 14/48] =?UTF-8?q?XML=20=EC=A0=81=EC=9E=AC=EA=B0=80=20?= =?UTF-8?q?=EC=9E=A5=EC=B9=98=20=EC=83=81=EC=8B=A4=EC=9D=84=20=EC=9D=BC?= =?UTF-8?q?=EB=B0=98=20GevException=20=EC=9C=BC=EB=A1=9C=20=EA=B0=90?= =?UTF-8?q?=EC=8B=B8=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=EC=9B=90=EB=9E=98=20?= =?UTF-8?q?=ED=98=95=EC=9C=BC=EB=A1=9C=20=EB=8D=98=EC=A7=84=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetXmlAsync/GetNodeMapAsync(GevXmlLoader.LoadAsync)는 First URL 이 어떤 이유로든 실패하면 Second URL 을 다시 시도하고, 끝내 실패하면 사유를 모은 GevException 하나를 던졌다. 그래서 적재 중 제어를 잃거나 장치가 응답하지 않거나 장치가 해제되면, 같은 포트를 거치는 Second URL 에 재시도 예산을 한 번 더 쓴 뒤 GevControlLostException·GevTimeoutException·ObjectDisposedException 이 InnerException 으로만 남았다. 형으로 "다시 연결" 과 "XML 이 틀렸다" 를 가르는 호출자는 이를 XML 오류로 잘못 분류한다. Local: 메모리 읽기는 포트 실패를 파일 이름을 붙인 GevException 으로 감싸 한 겹 더 가렸다. 바꾼 규칙: - URL 레지스터 읽기나 Local: 메모리 읽기에서 포트가 장치 상실(제어 상실·응답 없는 시한 초과·해제)을 알리면 다른 URL 로 넘어가지 않고 그 예외를 감싸지 않은 채 그대로 던진다. 두 번째 시도에서 잃어도 같다. - http 내려받기의 시한 초과는 서버 쪽 사정이라 장치 상실로 보지 않고 다른 URL 로 넘어간다. - 그 밖에 시도한 URL 이 모두 GevException 보다 구체적인 같은 형으로 실패했으면 첫 실패를 스택째 던진다. - 종류가 다른 실패(빈 레지스터 포함)는 전처럼 두 사유를 담은 GevException 으로 모은다. 재현: FakeMemPort 의 읽기 훅으로 First URL 레지스터 읽기에서 GevControlLostException·ObjectDisposedException 을 던지면 고치기 전에는 예외 없이 Second URL 로 적재가 끝났고, Local: 메모리 읽기의 GevTimeoutException 과 두 번째 시도의 제어 상실은 GevException 으로 감싸져 나왔으며, 두 URL 레지스터가 모두 ACCESS_DENIED 면 GevStatusException 대신 GevException 이 나왔다. 새 테스트 7건이 이 다섯 경우와 판정 함수·대조군(종류가 다른 실패는 여전히 모은다)을 본다. 공개 시그니처는 그대로이고 던지는 형만 달라졌다. LoadAsync·LoadFromUrlAsync· GetXmlAsync·GetNodeMapAsync 의 문서와 architecture.md 의 XML 적재 절에 규칙을 적었다. --- docs/architecture.md | 7 + src/GevSharp/GevDevice.GenApi.cs | 1 + src/GevSharp/GevDevice.Xml.cs | 6 + src/GevSharp/Xml/GevXmlLoader.cs | 72 +++++++++- tests/GevSharp.Tests/Xml/GevXmlLoaderTests.cs | 131 ++++++++++++++++++ 5 files changed, 212 insertions(+), 5 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index ab18647..d358c0a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -481,6 +481,13 @@ 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 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. An http download timeout is not +device loss and still falls back. 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`. Cache file name: `{Manufacturer}_{Model}_{DeviceVersion}_{FileName}` sanitized; cache is opt-in. ### GenApi (`GevSharp.GenApi`) diff --git a/src/GevSharp/GevDevice.GenApi.cs b/src/GevSharp/GevDevice.GenApi.cs index e18f7f2..391a4d9 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) { diff --git a/src/GevSharp/GevDevice.Xml.cs b/src/GevSharp/GevDevice.Xml.cs index 806b4bf..e515573 100644 --- a/src/GevSharp/GevDevice.Xml.cs +++ b/src/GevSharp/GevDevice.Xml.cs @@ -10,6 +10,12 @@ public sealed partial class GevDevice /// /// 카메라 XML 을 가져온다(First URL → Second URL 폴백, Local:/File:/http 3경로, ZIP 해제). /// 한 번 받으면 세션 동안 캐시한다. 디스크 캐시는 가 있을 때만. + /// + /// 적재 중 장치를 잃으면(, 응답 없는 , + /// 해제된 장치의 ) Second URL 로 넘어가지 않고 그 예외를 그대로 던진다 — 다시 연결할 일이다. + /// 두 URL 이 같은 종류로 실패하면 그 예외를, 다른 종류로 실패하면 두 사유를 담은 을 던진다 + /// (규칙 전체는 ). + /// /// public async Task GetXmlAsync(CancellationToken ct = default) { diff --git a/src/GevSharp/Xml/GevXmlLoader.cs b/src/GevSharp/Xml/GevXmlLoader.cs index 777bc73..74e0419 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; @@ -40,7 +41,16 @@ public static class GevXmlLoader /// /// First URL(0x0200) 을 읽어 XML 을 가져오고, 읽기·해석·가져오기 어느 단계든 실패하면 Second URL(0x0400) 로 넘어간다. /// Second URL 이 First URL 과 같은 문자열이면 같은 실패를 되풀이할 뿐이라(HTTP 시한·긴 메모리 읽기가 두 배가 된다) 다시 시도하지 않는다. - /// 둘 다 실패하면 두 사유를 모두 실은 . 취소는 그대로 전파된다. + /// + /// 던지는 것 — 호출자가 형으로 "다시 연결" 과 "XML 이 틀렸다" 를 가를 수 있게 원래 형을 지킨다: + /// 포트가 장치를 잃었다고 알리면(, , + /// — URL 레지스터 읽기나 Local: 메모리 읽기에서) 다른 URL 로 넘어가지 않고 그 예외를 + /// 감싸지 않은 채 그대로 던진다(다른 URL 도 같은 포트를 거쳐 재시도 예산만 한 번 더 쓴다). 두 번째 시도에서 잃었어도 같다. + /// http 내려받기의 시한 초과는 장치가 아니라 서버 쪽 사정이라 여기에 들지 않는다 — 다른 URL 로 넘어간다. + /// 그 밖에 시도한 URL 이 모두 같은 구체 형(예: 둘 다 )으로 실패했으면 첫 실패를 그대로 던지고, + /// 종류가 다르면(빈 레지스터 포함) 두 사유를 모두 실은 (마지막 실패가 InnerException). + /// 취소는 그대로 전파된다. + /// /// public static Task LoadAsync(IGevPort port, string? cacheDir = null, CancellationToken ct = default) => LoadAsync(port, cacheDir, null, ct); @@ -56,7 +66,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 +83,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 +104,14 @@ internal static async Task LoadAsync(IGevPort port, string? cacheDir, { throw; } + catch (Exception ex) when (IsDeviceLoss(ex, null)) + { + 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 +125,58 @@ internal static async Task LoadAsync(IGevPort port, string? cacheDir, { throw; } + catch (Exception ex) when (IsDeviceLoss(ex, url.Kind)) + { + 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 그 자체면 두 사유를 합쳐도 형이 같으므로 합친 쪽을 낸다. + 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 도 같은 포트를 거쳐 + /// 같은 이유로 실패하므로 넘어가지 않고 원래 형 그대로 던진다. fetchKind 는 가져오기 단계의 URL 종류(URL 레지스터 읽기 단계면 null) — + /// http 내려받기의 시한 초과는 서버 쪽 사정이라 장치 상실이 아니다. + /// + internal static bool IsDeviceLoss(Exception ex, GevXmlUrlKind? fetchKind) + => ex is GevControlLostException + || ex is ObjectDisposedException + || (ex is GevTimeoutException && fetchKind != GevXmlUrlKind.Http); + + // 예외가 하나 이상이고 전부 GevException 보다 구체적인 같은 형인지. + private static bool IsSameSpecificKind(List errors) + { + if (errors.Count == 0) return false; + var type = errors[0].GetType(); + if (type == typeof(GevException)) return false; + foreach (var e in errors) + { + if (e.GetType() != type) return false; + } + return true; } /// /// 해석된 URL 하나로 XML 을 가져온다(First/Second 폴백 없음). cacheDir 가 있으면 장치 식별 문자열로 캐시 파일을 찾고, /// 적중하면 XML 본문 전송 없이 캐시 텍스트를 돌려준다. 캐시 읽기·쓰기 실패는 경고 로그로만 남고 결과에는 영향이 없다. + /// 가져오기 실패는 사유와 출처를 실은 이되, Local: 메모리 읽기 중 포트가 장치를 잃었다고 알린 + /// ·· 은 감싸지 않고 그대로 던진다. + /// http 내려받기가 시한을 넘기면 . /// public static Task LoadFromUrlAsync(IGevPort port, GevXmlUrl url, string? cacheDir = null, CancellationToken ct = default) => LoadFromUrlAsync(port, url, cacheDir, null, ct); @@ -228,6 +285,11 @@ private static async Task ReadDeviceMemoryAsync(IGevPort port, GevXmlUrl { throw; } + catch (Exception ex) when (IsDeviceLoss(ex, GevXmlUrlKind.Local)) + { + // 장치 상실은 파일 이름을 붙여 감싸지 않는다 — 형이 곧 호출자의 판단 근거다(다시 연결). + 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); diff --git a/tests/GevSharp.Tests/Xml/GevXmlLoaderTests.cs b/tests/GevSharp.Tests/Xml/GevXmlLoaderTests.cs index 5efdccb..6f61ed3 100644 --- a/tests/GevSharp.Tests/Xml/GevXmlLoaderTests.cs +++ b/tests/GevSharp.Tests/Xml/GevXmlLoaderTests.cs @@ -371,6 +371,137 @@ 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; + } + + [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 초)을 실제로 기다려야 해서, 가르는 판정 자체를 본다. + var timeout = new GevTimeoutException("no reply"); + + Assert.False(GevXmlLoader.IsDeviceLoss(timeout, GevXmlUrlKind.Http)); + Assert.True(GevXmlLoader.IsDeviceLoss(timeout, GevXmlUrlKind.Local)); + Assert.True(GevXmlLoader.IsDeviceLoss(timeout, null)); // URL 레지스터 읽기 + Assert.True(GevXmlLoader.IsDeviceLoss(new GevControlLostException("lost"), null)); + Assert.True(GevXmlLoader.IsDeviceLoss(new ObjectDisposedException("GevDevice"), GevXmlUrlKind.Local)); + Assert.False(GevXmlLoader.IsDeviceLoss(new GevStatusException("READMEM", GvcpConst.StatusInvalidAddress), null)); + Assert.False(GevXmlLoader.IsDeviceLoss(new GevException("bad XML"), GevXmlUrlKind.Local)); + } + + [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); + } + // ---- ExtractXml ---- [Fact] From 45e217a492d04f3be1c77900ec7005f22baef989 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:09:38 +0900 Subject: [PATCH 15/48] =?UTF-8?q?=EB=AC=B4=ED=9A=A8=ED=99=94=EA=B0=80=20?= =?UTF-8?q?=EC=88=98=EC=8B=9D=20=EB=85=B8=EB=93=9C=EC=9D=98=20pVariable=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EA=B9=8C=EC=A7=80=20=EB=82=B4=EB=A0=A4?= =?UTF-8?q?=EA=B0=80=20=EB=9E=98=EC=B9=98=20=EB=92=A4=20=EB=A0=88=EC=A7=80?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=EB=A5=BC=20=EB=8B=A4=EC=8B=9C=20=EC=9D=BD?= =?UTF-8?q?=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INode.Invalidate() 는 의존 닫힘을 위로 모은 뒤 각 노드의 값 사슬(pValue/pValueDefault/pValueIndexed)을 아래로 버렸는데, 그 사슬에 수식 노드(SwissKnife/IntSwissKnife/Converter/IntConverter)의 pVariable 이 들어 있지 않았다. 그래서 값이 IntSwissKnife 로 두 레지스터(상위·하위 32비트)를 합쳐 읽는 노드는 Invalidate() 를 불러도 레지스터 캐시가 그대로라, 래치 명령을 실행하고 무효화한 뒤 읽어도 첫 래치 값이 나왔다. 타임스탬프 값 노드가 바로 이 모양이다. pInvalidator 를 레지스터가 아니라 값 노드에 둔 설명에서는 래치 쓰기의 자동 무효화도 같은 이유로 입력 레지스터에 닿지 않았다. 수식 입력을 값 사슬에 무조건 넣으면 너무 넓어진다 — 입력 하나(Width)를 무효화했을 때 의존으로 닿은 수식이 다른 입력(Height)까지 다시 읽는다(CacheInvalidationTests 의 Invalidate_DropsOwnValueChainAndDependents 가 읽기 4회로 깨지는 것을 실제로 돌려 확인했다). 그래서 둘을 가른다: - 낡았다고 선언된 노드 — Invalidate() 대상(쓰기가 실패한 노드 포함), pInvalidator 청취자, pSelected 대상 — 는 수식 노드의 값 pVariable 입력까지 내려가 버린다. - 의존으로만 닿은 노드는 그 입력 때문에 낡은 것이라 전처럼 수식 입력에서 멈춘다(그 입력은 따로 버려진다). - .Entry. 변수는 바인딩 때 굳은 상수, .Min/.Max/.Inc 는 한계를 읽는 변수라 값 사슬에 넣지 않는다(pMin/pMax 와 같다). 구현: NodeBase.CollectFormulaInputs 를 CollectValueTargets 와 따로 두고 네 수식 노드가 FormulaScope 의 값 변수 대상을 내놓는다. 무효화 닫힘이 노드마다 "선언된 낡음" 여부를 함께 돌려주고(두 길로 닿으면 선언 쪽), DropCacheChain 이 그 여부로 수식 입력까지 내려갈지 정한다. 재현: 손으로 쓴 XML(WO 래치 명령, 캐시되는 RO 레지스터 둘, 그 위의 IntSwissKnife, 그것을 pValue 로 쓰는 Integer)에서 읽기 → 장치 값 변경 → 래치 → 값 노드 Invalidate() → 읽기가 고치기 전에는 옛 값(4294967298)을 돌려줬다. 새 테스트 7건(Integer/IntSwissKnife/Float-SwissKnife/Converter/IntConverter 무효화, 값 노드의 pInvalidator, 형제 입력이 캐시에 남는 대조군) 중 대조군을 뺀 6건이 고치기 전에 실패했다. 새 파일이라 net48 레그에도 링크된다. INode.Invalidate 문서와 architecture.md 의 무효화 절에 규칙을 적었다. --- docs/architecture.md | 8 +- src/GevSharp/GenApi/GenApiNodeMap.Runtime.cs | 50 +++--- src/GevSharp/GenApi/INode.cs | 5 +- src/GevSharp/GenApi/Runtime/FloatNodes.cs | 4 + src/GevSharp/GenApi/Runtime/FormulaScope.cs | 13 ++ src/GevSharp/GenApi/Runtime/IntegerNodes.cs | 4 + src/GevSharp/GenApi/Runtime/NodeBase.cs | 16 +- .../Runtime/FormulaInvalidationTests.cs | 166 ++++++++++++++++++ 8 files changed, 242 insertions(+), 24 deletions(-) create mode 100644 tests/GevSharp.Tests/GenApi/Runtime/FormulaInvalidationTests.cs diff --git a/docs/architecture.md b/docs/architecture.md index d358c0a..062f10e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -607,7 +607,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 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..16e1c37 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(); } 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..19e1406 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); @@ -267,8 +274,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 +293,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/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)); // 형제 입력은 캐시에서 + } +} From 18fd901712f1b148334d42f031e35005c5a86e68 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:13:56 +0900 Subject: [PATCH 16/48] =?UTF-8?q?IsDone=20=EB=90=98=EC=9D=BD=EA=B8=B0?= =?UTF-8?q?=EA=B0=80=20=EB=AA=85=EB=A0=B9=EC=9D=98=20=EC=A0=91=EA=B7=BC=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=EB=A5=BC=20=EB=94=B0=EB=A5=B4=EA=B3=A0,=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EA=B7=9C=EC=B9=99=EA=B3=BC=20PollingTime?= =?UTF-8?q?=20=EC=9D=98=20=EC=93=B0=EC=9E=84=EC=9D=84=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EC=97=90=20=EC=A0=95=ED=99=95=ED=9E=88=20=EC=A0=81=EB=8A=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IsDoneAsync 는 Command 에 PollingTime 이 있으면 pValue 를 검사 없는 내부 값 경로로 되읽었다. 그 경로는 접근 모드를 보지 않으므로 pValue 레지스터가 쓰기 전용이거나, 명령에 ImposedAccessMode WO 가 붙었거나, 명령이 구현·가용하지 않아도(없는 기능의 주소 등) READREG 가 나갔다. 실장치는 쓰기 전용·없는 주소를 거절하고, 그 거절은 GenApiException 이 아니라 전송 예외로 올라와 GenApiException 만 잡는 호출자를 빠져나간다. 바꾼 동작(PollingTime 이 있을 때만 해당 — 없으면 전처럼 장치에 묻지 않고 참): - 되읽기 전에 명령 자신의 접근 모드(pValue 모드·ImposedAccessMode·술어 합성)를 본다. - 쓰기 전용이면 완료를 볼 길이 없으므로 포트에 닿지 않고 참(PollingTime 이 없을 때와 같은 뜻). 잠금은 쓰기만 막으므로 잠긴 쓰기 전용 명령(접근 모드가 NotAvailable 로 합성된다)도 쓰기 전용으로 본다. - 구현·가용하지 않으면 포트에 닿지 않고 사유를 담은 GenApiException. NodeBase.CanReadBackAsync 가 이 판정을 한다(내부). 재현: MemoryPort 는 거절하지 않으므로 포트 읽기 횟수로 본다. 고치기 전에는 WO 레지스터·ImposedAccessMode WO·잠긴 WO 명령 모두 레지스터를 읽고 명령 값(1)을 그대로 보아 IsDone 이 false 였고, pIsImplemented/pIsAvailable 이 0 인 명령은 예외 없이 레지스터를 읽었다. 새 테스트 5건이 이를 본다. 읽을 수 있는 명령의 되읽기(기존 테스트)는 그대로다. 문서: IsDone 은 이 라이브러리 고유 규칙이고 1차 자료와 대조하지 않았음을, Command 수준 PollingTime 이 없는 설명에서는 참이 완료 신호가 아님을(끝나기를 기다려야 하면 장치가 정한 상태 노드를 읽거나 안정 시간을 기다린다), PollingTime 은 값이 아니라 있는지만 쓰이고 폴링 간격은 호출자가 정한다는 것을 ICommand·CommandNode·NodeDef.PollingTimeMs· architecture.md·genapi-model.md 에 적었다. protocol-notes.md 의 "PollingTime 은 자기 소거 비트를 뜻한다" 는 프로토콜 사실처럼 적혀 있어 이 라이브러리의 해석이라고 고쳤다. 완료 규칙 자체는 근거 자료 없이 바꾸지 않았다. --- docs/architecture.md | 11 +++- docs/genapi-model.md | 2 +- docs/protocol-notes.md | 5 +- src/GevSharp/GenApi/INode.cs | 13 +++- src/GevSharp/GenApi/Model/NodeDef.cs | 5 +- src/GevSharp/GenApi/Runtime/NodeBase.cs | 13 ++++ src/GevSharp/GenApi/Runtime/OtherNodes.cs | 7 +- .../GenApi/Runtime/OtherNodeTests.cs | 64 +++++++++++++++++++ 8 files changed, 112 insertions(+), 8 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 062f10e..4186d3f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -576,8 +576,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` 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..236f600 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -243,7 +243,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/src/GevSharp/GenApi/INode.cs b/src/GevSharp/GenApi/INode.cs index 16e1c37..b8d19f8 100644 --- a/src/GevSharp/GenApi/INode.cs +++ b/src/GevSharp/GenApi/INode.cs @@ -142,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/NodeBase.cs b/src/GevSharp/GenApi/Runtime/NodeBase.cs index 19e1406..3f93adf 100644 --- a/src/GevSharp/GenApi/Runtime/NodeBase.cs +++ b/src/GevSharp/GenApi/Runtime/NodeBase.cs @@ -224,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) { 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/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() { From 74feb7198c8f201c7fbb12804dcff80f9d4324aa Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:01:32 +0900 Subject: [PATCH 17/48] =?UTF-8?q?SetTlParamsLockedAsync=20=EA=B0=80=20fals?= =?UTF-8?q?e=20=EB=A5=BC=20=EB=8F=8C=EB=A0=A4=EC=A3=BC=EB=8A=94=20?= =?UTF-8?q?=EB=91=90=20=EA=B2=BD=EC=9A=B0=EB=A5=BC=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=99=80=20=EB=AC=B8=EC=84=9C=EC=97=90=EC=84=9C=20=EA=B0=80?= =?UTF-8?q?=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TLParamsLocked 가 정수 노드가 아닌 기술에서도 "노드맵에 없다" 는 Debug 한 줄만 남아, 획득 커맨드가 잠긴 채 남은 이유를 로그에서 찾을 수 없었다. 노드가 없는 경우는 지금처럼 Debug, 같은 이름의 노드가 정수가 아닌 경우는 노드 종류를 적은 Warn 으로 나눈다. 반환값·예외 동작은 그대로다. architecture.md 의 API 목록이 반환형을 Task 로 적고 있던 것을 Task 로 바로잡고, false 가 나오는 두 조건을 목록과 본문에 적는다. 두 경우를 시뮬레이터에 자작 XML 을 실어 로그 수준·문구로 못 박는 시험을 더한다. --- docs/architecture.md | 7 +- src/GevSharp/GevDevice.GenApi.cs | 13 ++- .../Integration/TlParamsLockedTests.cs | 86 +++++++++++++++++++ 3 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 tests/GevSharp.Tests/Integration/TlParamsLockedTests.cs diff --git a/docs/architecture.md b/docs/architecture.md index 4186d3f..a445a93 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -203,7 +203,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 @@ -503,7 +504,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`, diff --git a/src/GevSharp/GevDevice.GenApi.cs b/src/GevSharp/GevDevice.GenApi.cs index 391a4d9..34488dc 100644 --- a/src/GevSharp/GevDevice.GenApi.cs +++ b/src/GevSharp/GevDevice.GenApi.cs @@ -40,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/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()); + } +} From 5853cb919de97ee3b38a401e42ddbf16a4c80270 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:06:26 +0900 Subject: [PATCH 18/48] =?UTF-8?q?=EC=A0=9C=EC=96=B4=20=EC=B1=84=EB=84=90?= =?UTF-8?q?=EC=9D=B4=20=EC=84=B8=EC=85=98=EB=B3=B4=EB=8B=A4=20=EB=A8=BC?= =?UTF-8?q?=EC=A0=80=20=EB=8B=AB=ED=9E=88=EB=A9=B4=20=EA=B7=B8=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=EC=97=90=EC=84=9C=20=EC=A0=9C=EC=96=B4=EA=B6=8C=20?= =?UTF-8?q?=EC=83=81=EC=8B=A4=EB=A1=9C=20=EB=84=98=EA=B8=B0=EA=B3=A0,=20Ob?= =?UTF-8?q?jectDisposedException=20=EC=9D=84=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=EC=97=90=20=EC=A0=81=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수신 소켓이 회복 불가로 실패하면 GvcpChannel 은 스스로 닫히는데, GevDevice 는 그것을 몰라 IsOpen 이 true 인 채로 모든 조작이 ObjectDisposedException(GvcpChannel) 으로 끝났다. 하트비트가 세 번 실패해야(기본 주기로 약 3 s) 상태가 바뀌었고, 하트비트가 없는 읽기 전용 세션은 그 상태에서 영영 벗어나지 못했다. 채널 Dispose() 를 밖에서 불러 닫힌 뒤의 상태를 만들어 재현했다(수신 루프가 스스로 닫을 때와 같은 메서드). 고치기 전: 조작이 GevControlLostException 이 아니라 ObjectDisposedException 을 던지고 IsOpen 이 true. 채널에 내부 콜백 OnClosed 를 두어 닫힐 때(누가 닫았든) 한 번 알리고, 수신 루프가 스스로 닫은 경우에는 그 원인을 넘긴다. GevDevice 는 열린 상태에서 그 알림을 받으면 곧바로 제어권 상실로 바꾼다 — IsOpen false, ControlLost 발생, 이후 조작은 GevControlLostException. 세션이 스스로 닫는 중이면 아무것도 하지 않는다. 하트비트 루프는 상태가 이미 열림이 아니면 끝나, 닫힌 채널에 세 번 실패하며 경고를 쌓지 않는다. 공개 API 는 바뀌지 않는다. 오류 계약: DisposeAsync 뒤의 장치 접근(앞서 받아 둔 노드맵의 노드 조작 포함)과 닫기와 겹친 요청이 GevException 이 아닌 ObjectDisposedException 을 던진다는 것을 architecture.md 의 Device 절· 오류 색인과 GevDevice 문서 주석에 적는다. 이미 받아 둔 XML·노드맵은 닫힌 뒤에도 캐시에서 돌려준다는 것을 실행으로 확인해 함께 적고 시험으로 못 박는다. --- docs/architecture.md | 17 ++++- src/GevSharp/GevDevice.cs | 35 ++++++++++- src/GevSharp/Gvcp/GvcpChannel.cs | 35 ++++++++++- .../Integration/DeviceLifecycleTests.cs | 63 +++++++++++++++++++ 4 files changed, 144 insertions(+), 6 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index a445a93..791b76f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -186,7 +186,7 @@ public sealed class GevDevice : IGevPort, IAsyncDisposable 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; a uint register, saturated at int.MaxValue (never negative) @@ -229,6 +229,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 @@ -655,7 +668,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` | diff --git a/src/GevSharp/GevDevice.cs b/src/GevSharp/GevDevice.cs index a091452..a19c902 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 { @@ -54,6 +61,7 @@ private GevDevice(IPEndPoint device, IPAddress localAddress, GevDeviceOpt opt) // 장치가 열리지 못한다. 채널 기본값으로 열고, 하트비트를 시작하기 직전에 InitAsync 가 실제 값으로 좁힌다. MaxPendingAckWaitMs = opt.MaxPendingAckWaitMs ?? GvcpChannelOpt.DefaultMaxPendingAckWaitMs, }); + Gvcp.OnClosed = OnChannelClosed; _logSrc = $"{LogSrc} {Address}"; } @@ -64,7 +72,10 @@ 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; } @@ -248,6 +259,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 @@ -319,6 +332,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 로 바꾸고 이벤트를 스레드 풀에서 올린다. 하트비트 태스크 안에서 직접 부르면 /// 핸들러가 를 기다릴 때 그 태스크 자신을 기다리게 되어 멈춘다 — 그래서 분리한다. @@ -364,7 +392,10 @@ private void ThrowIfClosed() } } - /// 하트비트를 멈추고, 제어 중이면 CCP = 0 을 써서 놓고, 채널을 닫는다. 몇 번 불러도 안전하다. + /// + /// 하트비트를 멈추고, 제어 중이면 CCP = 0 을 써서 놓고, 채널을 닫는다. 몇 번 불러도 안전하다. + /// 그 뒤 장치에 닿는 조작은 전부 이다( 이 아니다). + /// public async ValueTask DisposeAsync() { var previous = Interlocked.Exchange(ref _state, StateDisposed); diff --git a/src/GevSharp/Gvcp/GvcpChannel.cs b/src/GevSharp/Gvcp/GvcpChannel.cs index 083eac4..2e4f151 100644 --- a/src/GevSharp/Gvcp/GvcpChannel.cs +++ b/src/GevSharp/Gvcp/GvcpChannel.cs @@ -72,6 +72,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; @@ -119,6 +121,13 @@ 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; } /// 이 채널이 실제로 쓰는 타이밍 값(생성자에 넘긴 객체의 사본). 진단용으로 읽는다. @@ -405,7 +414,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; } @@ -497,7 +508,11 @@ private void ThrowIfDisposed() if (_isDisposed) throw new ObjectDisposedException(nameof(GvcpChannel)); } - /// 소켓을 닫고 수신 스레드가 끝나기를 기다린다. 대기 중인 요청은 으로 끝난다. + /// + /// 소켓을 닫고 수신 스레드가 끝나기를 기다린다. 대기 중인 요청은 으로 끝나고, + /// 그 뒤의 요청도 전부 이다. 이 채널을 쥔 가 아직 열려 있었다면 + /// 그 세션은 이 자리에서 제어권 상실로 넘어간다. + /// public void Dispose() { if (_isDisposed) return; @@ -512,6 +527,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"); @@ -519,6 +535,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/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs b/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs index b0de020..ec83bb1 100644 --- a/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs +++ b/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs @@ -310,6 +310,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] From 849489fdc2a8e6c1461ca5254bc4482c288ed837 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:09:49 +0900 Subject: [PATCH 19/48] =?UTF-8?q?=EC=8B=9C=EB=AE=AC=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=ED=83=80=EC=9E=84=EC=8A=A4=ED=83=AC=ED=94=84=20?= =?UTF-8?q?=EC=A0=9C=EC=96=B4=EB=A5=BC=201=20=3D=20reset,=202=20=3D=20latc?= =?UTF-8?q?h=20=EB=A1=9C=20=EB=B0=94=EB=A1=9C=EC=9E=A1=EA=B3=A0=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EA=B3=84=EC=B8=B5=20=EC=9D=B4=EB=A6=84(Ge?= =?UTF-8?q?vTimestamp*)=EC=9D=84=20=EC=8B=A3=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시뮬레이터는 부트스트랩 0x0944 에 2 를 쓰면 카운터를 지우고 1 을 쓰면 래치했다. 실제 장치의 기술 두 벌(로컬 검증용 벤더 XML)과 공개 부트스트랩 기술이 모두 GevTimestampControlReset 에 CommandValue 1, GevTimestampControlLatch 에 CommandValue 2 를 싣는다 — 즉 반대다. 이 차이 때문에 표준 이름으로 래치하는 호스트는 시뮬레이터에서 카운터를 지우게 된다. 고치기 전: 1(reset)을 쓴 뒤 래치 레지스터가 0 이어야 하는데 13805600 이 실렸다. - SimDevice: 1 = reset(래치 값은 그대로), 2 = latch. 둘 다면 reset 뒤 latch. - SimCamera.xml: TimestampLatch 의 CommandValue 를 2 로, TimestampReset(1) 을 더하고, TransportLayerControl 아래에 GevTimestampControlReset/GevTimestampControlLatch/GevTimestampValue/ GevTimestampTickFrequency 를 같은 레지스터 위에 더한다. 기존 이름은 시험이 쓰고 있고 그 자체로 표준 이름 가족이라 남긴다. 장치 시계로 프레임을 짝짓는 하류 코드가 찾는 이름이 이쪽이다. - 라이브러리 GvbsAddr 주석과 protocol-notes.md 가 같은 값을 뒤바꿔 적고 있던 것을 바로잡는다 (라이브러리 코드에는 이 레지스터를 쓰는 곳이 없어 동작은 바뀌지 않는다). - sim-register-map.md 에 두 이름 가족과 값을 적는다. 시험: 원시 GVCP 로 1 뒤 래치 레지스터가 0 으로 남는지(시각과 무관한 판별) 확인하도록 바꾸고, 노드맵으로 GevTimestamp* 가 reset·latch·값·주파수를 다루며 두 이름 가족이 같은 레지스터를 보는지 확인하는 시험을 더한다. --- docs/protocol-notes.md | 4 +- docs/sim-register-map.md | 15 +++++-- src/GevSharp/Gvcp/GvbsAddr.cs | 2 +- tests/GevSharp.Sim/Assets/SimCamera.xml | 45 ++++++++++++++++++- tests/GevSharp.Sim/SimDevice.Gvcp.cs | 7 +-- .../GenApi/Runtime/SimNodeMapTests.cs | 25 +++++++++++ tests/GevSharp.Tests/Sim/SimGvcpTests.cs | 10 ++++- 7 files changed, 96 insertions(+), 12 deletions(-) diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 236f600..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 diff --git a/docs/sim-register-map.md b/docs/sim-register-map.md index 5ef5153..8a9ccb3 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 | 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/tests/GevSharp.Sim/Assets/SimCamera.xml b/tests/GevSharp.Sim/Assets/SimCamera.xml index 644192d..fcbdf68 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
@@ -751,6 +763,10 @@ GevCCP GevSCCExtendedIds GevSCCFGExtendedIds + GevTimestampTickFrequency + GevTimestampControlReset + GevTimestampControlLatch + GevTimestampValue TLParamsLocked @@ -898,6 +914,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..1b62566 100644 --- a/tests/GevSharp.Sim/SimDevice.Gvcp.cs +++ b/tests/GevSharp.Sim/SimDevice.Gvcp.cs @@ -457,10 +457,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.Tests/GenApi/Runtime/SimNodeMapTests.cs b/tests/GevSharp.Tests/GenApi/Runtime/SimNodeMapTests.cs index 1d9517a..aec9ba7 100644 --- a/tests/GevSharp.Tests/GenApi/Runtime/SimNodeMapTests.cs +++ b/tests/GevSharp.Tests/GenApi/Runtime/SimNodeMapTests.cs @@ -302,6 +302,31 @@ 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()); + + await reset.ExecuteAsync(); + Assert.Equal(0, await value.GetAsync()); // reset 은 래치하지 않는다 + await Task.Delay(20); + await latch.ExecuteAsync(); + 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"); + + // 두 이름 가족은 같은 레지스터를 본다. + 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/Sim/SimGvcpTests.cs b/tests/GevSharp.Tests/Sim/SimGvcpTests.cs index 436eef4..2596e3d 100644 --- a/tests/GevSharp.Tests/Sim/SimGvcpTests.cs +++ b/tests/GevSharp.Tests/Sim/SimGvcpTests.cs @@ -579,9 +579,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]; From 5ea0d1be05785817320f66376249b84091a46c34 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:11:55 +0900 Subject: [PATCH 20/48] =?UTF-8?q?=EC=8B=9C=EB=AE=AC=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EA=B0=80=20=ED=9A=8D=EB=93=9D=20=EC=A4=91=20Acquisiti?= =?UTF-8?q?onMode=20=EC=99=80=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=98=95?= =?UTF-8?q?=EC=8B=9D=20=ED=94=BC=EC=B2=98=EB=A5=BC=20=EC=9E=A0=EA=B7=BC?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시뮬레이터 XML 은 Width/Height/PixelFormat 만 획득 중 잠그고 AcquisitionMode 는 잠그지 않아, 획득이 도는 동안 노드맵으로 모드를 바꿔도 통과했다. 실제 카메라에는 획득 중 AcquisitionMode· OffsetX/OffsetY·ReverseX 까지 잠그는 기종이 있고(로컬 검증용 벤더 XML 에서 확인), 하류는 단발 그랩 뒤 AcquisitionStop 을 빠뜨리면 다음 연속 획득으로 못 넘어가는 그 동작을 막으려 한다. 시뮬레이터에서 그 거절이 나지 않으면 그 방어가 빠져도 하류 시험이 초록으로 남는다. 고치기 전: 획득 중 AcquisitionMode = SingleFrame 쓰기가 예외 없이 통과했다. AcquisitionMode, OffsetX, OffsetY, ReverseX 에 pIsLocked = AcquisitionActive 를 단다. 잠금은 XML 에만 있고 원시 레지스터 쓰기는 거절하지 않는다(SimFeatureAddr 를 직접 쓰는 시험이 그에 기댄다). 잠금은 AcquisitionStatus 를 따르므로 SingleFrame/MultiFrame 이 제 몫을 다 보내면 그 자리에서 풀린다. TriggerMode/TriggerSource 는 기종마다 잠그는 조건이 달라 잠그지 않는다. 기존 시험 중 획득 중에 이 피처를 노드맵으로 바꾸던 것은 없었다(전체 스위트 통과). sim-register-map.md 의 표와 제약 절을 이에 맞춘다. --- docs/sim-register-map.md | 20 ++++++---- tests/GevSharp.Sim/Assets/SimCamera.xml | 14 ++++--- .../GenApi/Runtime/SimNodeMapTests.cs | 40 +++++++++++++++++++ 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/docs/sim-register-map.md b/docs/sim-register-map.md index 8a9ccb3..c4a6d0a 100644 --- a/docs/sim-register-map.md +++ b/docs/sim-register-map.md @@ -98,23 +98,23 @@ reset the counter instead. |---|---|---|---|---|---| | `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 | — | @@ -219,8 +219,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/tests/GevSharp.Sim/Assets/SimCamera.xml b/tests/GevSharp.Sim/Assets/SimCamera.xml index fcbdf68..2e106cf 100644 --- a/tests/GevSharp.Sim/Assets/SimCamera.xml +++ b/tests/GevSharp.Sim/Assets/SimCamera.xml @@ -238,7 +238,8 @@ - Horizontal offset of the region of interest + Horizontal offset of the region of interest; locked while acquiring + AcquisitionActive Yes OffsetXReg 0 @@ -256,7 +257,8 @@ - Vertical offset of the region of interest + Vertical offset of the region of interest; locked while acquiring + AcquisitionActive Yes OffsetYReg 0 @@ -343,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 @@ -380,7 +383,8 @@ - Continuous, SingleFrame or MultiFrame + Continuous, SingleFrame or MultiFrame; locked while acquiring + AcquisitionActive Yes 0 @@ -445,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 diff --git a/tests/GevSharp.Tests/GenApi/Runtime/SimNodeMapTests.cs b/tests/GevSharp.Tests/GenApi/Runtime/SimNodeMapTests.cs index aec9ba7..0d3e78d 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() { From 79b0d8cd7ecae9d756ff7b50be9d1e26d50d7a83 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:14:50 +0900 Subject: [PATCH 21/48] =?UTF-8?q?=EC=8B=9C=EB=AE=AC=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EC=97=90=20=EC=9E=A5=EC=B9=98=20=EC=9E=AC=EB=B6=80?= =?UTF-8?q?=ED=8C=85=EC=9D=84=20=ED=9D=89=EB=82=B4=20=EB=82=B4=EB=8A=94=20?= =?UTF-8?q?Reboot()=20=EB=A5=BC=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop()/Start() 는 재부팅이 아니다 — 제어권 보유자와 CCP·레지스터가 그대로 남아, 하트비트를 보내던 호스트가 계속 보유자로 보인다. 그래서 GevDevice 의 "장치가 재시작했다" 계열 제어권 상실 경로를 시뮬레이터로 시험할 수 없었고, 임시 포트면 다시 시작할 때 포트까지 바뀌었다. 고치기 전(같은 포트로 Stop/Start 한 것을 재부팅 자리에 두고 돌림): 재부팅 뒤에도 보유자가 127.0.0.1:54405 로 남았다. Reboot() 는 소켓은 그대로 두고, 처리 중인 GVCP 명령이 끝난 뒤 다음 명령 전에 휘발 상태를 한꺼번에 켜진 직후로 되돌린다: 획득 정지, 보유자·CCP·PrimaryApp, 하트비트 타임아웃, GVCP 설정, 타임스탬프 카운터(0 부터)·래치, 스트림 채널 0 설정, 피처 페이지, 블록 ID(다음은 1), 리센드 이력, 무장된 트리거. 영속 IP·사용자 이름·관찰 카운터는 남는다. 명령 처리와 재부팅은 새 잠금으로 서로 배제하고, 생성 시 초기화와 재부팅이 같은 부트스트랩 초기화 함수를 쓰게 나눈다. 시험: 재부팅 뒤 같은 엔드포인트·CCP 0·ControlOwnerChanged(null)·획득 정지·초기값을 확인하고, 열려 있던 GevDevice 가 "device restarted" 사유로 제어권 상실을 알리며 새 세션이 곧바로 제어권을 잡는지 본다. sim-register-map.md 에 동작과 Stop/Start 와의 차이를 적는다. --- docs/sim-register-map.md | 10 +++ tests/GevSharp.Sim/SimDevice.Gvcp.cs | 14 +-- tests/GevSharp.Sim/SimDevice.cs | 88 +++++++++++++++---- .../Integration/DeviceLifecycleTests.cs | 41 +++++++++ 4 files changed, 132 insertions(+), 21 deletions(-) diff --git a/docs/sim-register-map.md b/docs/sim-register-map.md index c4a6d0a..d19c80c 100644 --- a/docs/sim-register-map.md +++ b/docs/sim-register-map.md @@ -169,6 +169,16 @@ 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), 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 diff --git a/tests/GevSharp.Sim/SimDevice.Gvcp.cs b/tests/GevSharp.Sim/SimDevice.Gvcp.cs index 1b62566..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) { diff --git a/tests/GevSharp.Sim/SimDevice.cs b/tests/GevSharp.Sim/SimDevice.cs index fc30a84..ecc0d4f 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]; @@ -185,7 +190,10 @@ public void Start() thread.Start(); } - /// 획득을 멈추고 소켓을 닫고 스레드를 거둔다. 여러 번 불러도 된다. 레지스터 내용은 남는다. + /// + /// 획득을 멈추고 소켓을 닫고 스레드를 거둔다. 여러 번 불러도 된다. 레지스터 내용과 제어권 보유자는 남는다 — + /// 장치 재시작을 흉내 내려면 를 쓴다. + /// public void Stop() { _isStopping = true; @@ -207,6 +215,43 @@ 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() + { + IPEndPoint? previousOwner; + lock (_commandGate) + { + StopAcquisition(join: true); + 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(); + } + if (previousOwner is not null) ControlOwnerChanged?.Invoke(null); + } + /// 피처 페이지를 생성 시 옵션 값으로 되돌린다(UserSetLoad). FrameCounter 는 유지한다. public void ResetFeatures() { @@ -287,27 +332,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 +378,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/Integration/DeviceLifecycleTests.cs b/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs index ec83bb1..f20d0bc 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] From 716105247b2811fbb323286bc8be0785c8e101be Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:15:58 +0900 Subject: [PATCH 22/48] =?UTF-8?q?=EC=8B=9C=EB=AE=AC=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=AC=B8=EC=84=9C=EC=97=90=20=EC=9E=AC=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EB=AA=85=EB=A0=B9=EC=9D=98=20=EC=9E=AC=EC=8B=A4?= =?UTF-8?q?=ED=96=89=EA=B3=BC=20=EC=8B=9C=ED=97=98=EC=9A=A9=20=ED=98=B8?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=83=80=EC=9D=B4=EB=B0=8D=EC=9D=84=20?= =?UTF-8?q?=EC=A0=81=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시뮬레이터 응답기는 req_id 를 기억하지 않아 같은 req_id 로 다시 온 명령도 다시 실행한다 — 라이브러리는 응답 창 안에 ACK 가 없으면 같은 req_id 로 재전송하므로, 느린 왕복에서는 자기 소거 명령이 두 번 돈다(단발 그랩에 프레임 하나가 더 나오는 식). 이 사실과, 제품 기본 타이밍 (GvcpTimeoutMs 500·재시도 3, 하트비트 타임아웃 3000 → 주기 1000)이 굶주린 러너에서 시뮬레이터를 상대로 실패했다는 기록이 시험 리그 주석에만 있고 공개 문서에는 없었다. sim-register-map.md 에 "중복 억제 없음" 항목과 "시뮬레이터 상대 시험의 호스트 타이밍" 절을 더해, SimRig 가 쓰는 값(3000/1, 10 000/500)과 그 이유, 하류 시험도 두 설정을 넓혀야 한다는 것을 적는다. 재실행 동작은 같은 바이트·같은 req_id 의 래치 명령을 두 번 보내 두 번째가 더 늦은 값을 싣는 것으로 실행해 확인했고, 그 시험을 남긴다. 동작 변경은 없다. --- docs/sim-register-map.md | 19 +++++++++++++++ tests/GevSharp.Tests/Sim/SimGvcpTests.cs | 30 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/docs/sim-register-map.md b/docs/sim-register-map.md index d19c80c..c176ad5 100644 --- a/docs/sim-register-map.md +++ b/docs/sim-register-map.md @@ -146,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 @@ -191,6 +196,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 diff --git a/tests/GevSharp.Tests/Sim/SimGvcpTests.cs b/tests/GevSharp.Tests/Sim/SimGvcpTests.cs index 2596e3d..1663f75 100644 --- a/tests/GevSharp.Tests/Sim/SimGvcpTests.cs +++ b/tests/GevSharp.Tests/Sim/SimGvcpTests.cs @@ -597,6 +597,36 @@ public void TimestampLatch_CapturesRunningCounter() Assert.Equal(0u, v[2]); } + [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() { From 05631923d3e11b735da06edf89308b776f8fad9a Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 13:57:51 +0900 Subject: [PATCH 23/48] =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A6=BC=20=EC=A0=95?= =?UTF-8?q?=EC=A7=80=EA=B0=80=20=ED=98=B8=EC=B6=9C=EC=9E=90=EC=9D=98=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=EB=A1=9C=20=EC=9E=A5=EC=B9=98=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EB=81=84=EA=B8=B0=EC=99=80=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=EB=A5=BC=20=EA=B1=B4=EB=84=88=EB=9B=B0?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StopAsync 는 호출자의 토큰을 자물쇠 대기와 SCP = 0 / SCDA = 0 쓰기에 그대로 넘겼다. 그래서 - 이미 취소된 토큰이면 SemaphoreSlim 이 비어 있는 자물쇠도 잡지 않고 곧장 취소로 끝나, 소켓 닫기· 큐 비우기·정지 상태 전환을 하나도 하지 않은 채 TaskCanceledException 을 던졌다. - SCP 쓰기 도중 취소가 오면 그 취소가 "SCP = 0 쓰기 실패" 경고로 기록되고, 이어지는 SCDA 쓰기는 이미 취소된 토큰을 받아 보내지도 못했다. 그런데도 정지는 정상으로 돌아와 상태가 Stopped 가 되므로, 뒤따르는 DisposeAsync 도 다시 시도하지 않았다 — 장치는 닫힌 포트를 향해 계속 쏜다. 이제 자물쇠 대기와 장치 전송 끄기는 토큰과 무관하게 한다(실패한 시작의 SCP 되돌리기가 이미 그렇게 한다). 대기는 모두 스스로 상한이 있다 — 겹친 시작·정지는 제어 채널 시한과 합류 2 초에, 레지스터 쓰기는 제어 채널의 시한·재시도에 묶인다. 취소돼 있어도 정지를 끝까지 하고 정상으로 돌아온다: 돌아왔다면 스트림은 멈춘 것이고, 다 멈춘 뒤 취소 예외를 던지면 셧다운을 취소 처리로 감싼 호출자가 뒤따르는 장치 닫기를 건너뛰게 된다. 수신 스레드 합류도 토큰으로 줄이지 않는다 — 합류를 건너뛰면 큐를 비우는 사이 수신 스레드가 프레임을 하나 더 넣을 수 있고, 그 버퍼는 닫힌 큐에 남아 돌아오지 않는다. 테스트: 도중 취소(SCP = 0 이 나가는 순간 취소)에도 SCDA = 0 이 나가는지, 이미 취소된 토큰으로도 장치 끄기·큐 비우기·풀 반납·정지 상태까지 가는지. 수정 전에는 각각 "SCDA = 0 없음" 과 TaskCanceledException 으로 실패했다. StopAsync 문서와 architecture.md 의 StopAsync 줄에 토큰의 의미를 적는다. --- docs/architecture.md | 4 +- src/GevSharp/Gvsp/GevStream.cs | 22 +++++++-- tests/GevSharp.Tests/Gvsp/GevStreamTests.cs | 49 +++++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 791b76f..718592d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -326,7 +326,9 @@ 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 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); diff --git a/src/GevSharp/Gvsp/GevStream.cs b/src/GevSharp/Gvsp/GevStream.cs index c18fb7a..e3e00a9 100644 --- a/src/GevSharp/Gvsp/GevStream.cs +++ b/src/GevSharp/Gvsp/GevStream.cs @@ -196,7 +196,18 @@ public async Task StartAsync(CancellationToken ct = default) /// 장치 전송을 끄고(SCP = 0, SCDA = 0) 소켓을 닫아 수신 스레드를 깨운 뒤 합류한다. 조립 중이던 프레임은 버려지고, /// 큐에 남은 프레임은 반납되며, 대기 중인 는 으로 끝난다. /// 여러 번 불러도 된다. 레지스터 쓰기 실패는 로그만 남기고 로컬 정리는 끝까지 진행한다. + /// + /// 토큰은 정지를 끊지 않는다. 이미 취소된 토큰이 와도, 도중에 취소돼도 위 단계를 전부 밟고 정상으로 돌아온다 — + /// 돌아왔다면 스트림은 멈춘 것이다. 반쯤 멈춘 스트림은 온전히 도는 것보다 나쁘다: 장치 전송 끄기를 건너뛰면 장치가 + /// 닫힌 포트를 향해 계속 쏘고, 로컬 정리를 건너뛰면 큐에 든 버퍼가 돌아오지 않는다. 다 멈춘 뒤에 취소 예외를 던지지도 않는다 — + /// 셧다운을 취소 처리로 감싼 호출자는 그 예외 때문에 뒤따르는 정리(장치 닫기 등)를 건너뛰게 되는데, 정작 정지는 끝나 있다. + /// + /// + /// 기다리는 자리마다 상한이 따로 있다: 겹친 시작·정지가 끝나기를(그쪽도 아래 상한에 묶인다), 레지스터 쓰기는 제어 채널의 + /// 시한·재시도를, 수신 스레드 합류는 2 초를 넘지 않는다. + /// ///
+ /// 어떤 단계도 끊지 않는다(위 설명). 취소돼 있어도 정지를 끝까지 하고 정상으로 돌아온다. public async Task StopAsync(CancellationToken ct = default) { var thread = _thread; @@ -205,7 +216,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,9 +231,11 @@ public async Task StopAsync(CancellationToken ct = default) _state = StateStopping; _isStopRequested = true; - try { await WriteRegAsync(GvbsAddr.ScpOffset, 0, ct).ConfigureAwait(false); } + // 장치 전송 끄기는 호출자의 토큰과 무관하게 시도한다(실패한 시작의 되돌리기와 같다). 토큰을 넘기면 SCP 쓰기 도중의 + // 취소가 "SCP 쓰기 실패" 로 기록되고, 이미 취소된 토큰을 받은 SCDA 쓰기는 보내지도 못한 채 끝난다. + try { await WriteRegAsync(GvbsAddr.ScpOffset, 0, CancellationToken.None).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); } + try { await WriteRegAsync(GvbsAddr.ScdaOffset, 0, CancellationToken.None).ConfigureAwait(false); } catch (Exception ex) { GevLog.Warn(_logSrc, "Failed to write SCDA = 0 while stopping the stream.", ex); } var socket = _socket; diff --git a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs index 14f2ad4..d4971dd 100644 --- a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs +++ b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs @@ -426,6 +426,55 @@ 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 void ReceiveBeforeStartThrows() { From 98992acd215b2af46405fe228ac51bfdbd423fcb Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 13:59:20 +0900 Subject: [PATCH 24/48] =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A6=BC=20=EC=86=8C?= =?UTF-8?q?=EC=BC=93=20=EC=83=9D=EC=84=B1=EC=9D=B4=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=ED=95=B4=EB=8F=84=20=EC=8B=9C=EC=9E=91=EC=9D=B4=20=EC=A0=95?= =?UTF-8?q?=EC=A7=80=20=EC=83=81=ED=83=9C=EB=A1=9C=20=EB=81=9D=EB=82=98?= =?UTF-8?q?=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StartAsync 는 상태를 Starting 으로 세운 뒤, 실패 시 상태를 Stopped 로 되돌리는 try 밖에서 소켓을 만들었다. 핸들·버퍼 고갈로 소켓 생성이 던지면 상태가 Starting 에 걸린 채 남아, 다시 시작하려는 쪽은 "Stream is already started." 라는 엉뚱한 답을 받고 정지는 아무것도 쓴 적 없는 장치에 SCP/SCDA = 0 을 보낸다. 소켓 생성을 try 안으로 옮겨, 시작 실패는 어느 단계에서 나든 소켓을 닫고 Stopped 로 끝난다. 생성 실패를 밖에서 만들 수 없어, 인터페이스 MTU 조회처럼 소켓 생성을 바꿔 끼우는 내부 창구 (SocketFactory, 기본 null 이면 종전과 같은 소켓)를 둔다. 테스트는 생성이 던지게 한 뒤 재시작이 "cannot be restarted" 로 거절되고 정지가 장치에 아무것도 쓰지 않는지 본다 — 수정 전에는 "Stream is already started." 로 실패했다. StartAsync 문서에 소켓 생성·바인드 실패도 같은 결말임을 적는다. --- src/GevSharp/Gvsp/GevStream.cs | 12 +++++++++--- tests/GevSharp.Tests/Gvsp/GevStreamTests.cs | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/GevSharp/Gvsp/GevStream.cs b/src/GevSharp/Gvsp/GevStream.cs index e3e00a9..be17478 100644 --- a/src/GevSharp/Gvsp/GevStream.cs +++ b/src/GevSharp/Gvsp/GevStream.cs @@ -95,9 +95,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) { @@ -112,10 +115,13 @@ public async Task StartAsync(CancellationToken ct = default) } _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; @@ -174,7 +180,7 @@ public async Task StartAsync(CancellationToken ct = default) { _socket = null; _isStopRequested = true; - socket.Close(); + socket?.Close(); if (hasWrittenScp) { // 장치가 닫힌 포트로 쏘지 않게 최선을 다해 되돌린다 — 여기서의 실패는 원래 예외를 가리지 않는다. diff --git a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs index d4971dd..76664ad 100644 --- a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs +++ b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs @@ -572,6 +572,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() { From 4eb0cfaf5739eb58aaff90ad91ca8ccace77e016 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:03:56 +0900 Subject: [PATCH 25/48] =?UTF-8?q?=EC=88=98=EC=8B=A0=20=EC=8A=A4=EB=A0=88?= =?UTF-8?q?=EB=93=9C=EA=B0=80=20=EC=8A=A4=EC=8A=A4=EB=A1=9C=20=EB=81=9D?= =?UTF-8?q?=EB=82=98=EB=A9=B4=20IsStarted=20=EA=B0=80=20=EA=B1=B0=EC=A7=93?= =?UTF-8?q?=EC=9D=B4=20=EB=90=98=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스트림 소켓이 죽어(NIC 가 내려가는 등) 수신 스레드가 정지 요청 없이 끝나면 큐는 닫혀 ReceiveAsync 가 GevStreamClosedException 을 던지는데, 상태는 Started 에 남아 IsStarted 가 계속 참이었다. 그 값으로 "다시 열기" 와 "이미 멈춤" 을 가르는 호출자가 속는다. 이제 수신 스레드는 정지 요청 없이 끝날 때 큐를 닫기 전에 상태를 Started 에서 새 내부 상태 Faulted 로 내린다(CAS 라 정지가 이미 상태를 가져갔으면 건드리지 않는다). 닫힘을 받은 소비자는 IsStarted 도 거짓으로 본다. Faulted 는 Stopped 와 다르다 — 장치 전송 끄기와 큐의 버퍼 반납은 아직이라, StopAsync/DisposeAsync 는 이 상태를 살아 있는 스트림처럼 끝까지 정리한다. 재시작은 거절하되 그 사유를 따로 알린다. StartAsync 는 상태를 스레드를 띄우기 전에 Started 로 세운다 — 소켓이 곧장 죽어 수신 스레드가 먼저 상태를 내린 뒤 늦게 Started 를 쓰면 죽은 스트림이 시작된 것으로 남는다. 소켓 사망을 밖에서 만들 수 없어, 정지 요청 없이 스트림 소켓만 닫는 내부 창구(KillSocketForTest)를 둔다. 테스트는 그 뒤 큐의 장을 받고 닫힘을 받은 다음 IsStarted 가 거짓인지, 재시작이 거절되는지, 정지가 SCP/SCDA = 0 을 쓰고 풀 버퍼를 전부 돌려받는지 본다 — 수정 전에는 닫힘 뒤 IsStarted 가 참이라 실패했다. IsStarted·ReceiveAsync·GevStreamClosedException 문서와 architecture.md 의 IsStarted 줄에 이 경로를 적는다. --- docs/architecture.md | 5 ++- src/GevSharp/GevException.cs | 5 ++- src/GevSharp/Gvsp/GevStream.Receiver.cs | 8 +++++ src/GevSharp/Gvsp/GevStream.cs | 37 ++++++++++++++++++--- tests/GevSharp.Tests/Gvsp/GevStreamTests.cs | 30 +++++++++++++++++ 5 files changed, 78 insertions(+), 7 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 718592d..d7852c4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -315,7 +315,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. diff --git a/src/GevSharp/GevException.cs b/src/GevSharp/GevException.cs index 40f8a5a..979021c 100644 --- a/src/GevSharp/GevException.cs +++ b/src/GevSharp/GevException.cs @@ -77,7 +77,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/Gvsp/GevStream.Receiver.cs b/src/GevSharp/Gvsp/GevStream.Receiver.cs index ceb5133..3e8f15a 100644 --- a/src/GevSharp/Gvsp/GevStream.Receiver.cs +++ b/src/GevSharp/Gvsp/GevStream.Receiver.cs @@ -314,6 +314,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,12 +323,19 @@ 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."); } } + /// + /// 수신 스레드가 정지 요청 없이 끝날 때 상태를 "시작됨" 에서 "스스로 끝남" 으로 내린다. 큐를 닫기 **전에** 불러야 + /// 닫힘을 받은 소비자가 도 거짓으로 본다. 정지가 이미 상태를 가져갔으면 아무것도 바꾸지 않는다. + /// + private void MarkReceiverEnded() => Interlocked.CompareExchange(ref _state, StateFaulted, StateStarted); + /// 수신 오류 분류. 계속 돌아도 되면 true, 루프를 끝내야 하면 false. private bool HandleReceiveError(SocketException ex) { diff --git a/src/GevSharp/Gvsp/GevStream.cs b/src/GevSharp/Gvsp/GevStream.cs index be17478..ad0f385 100644 --- a/src/GevSharp/Gvsp/GevStream.cs +++ b/src/GevSharp/Gvsp/GevStream.cs @@ -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; @@ -87,6 +92,16 @@ internal GevStream(IGevPort regs, IGvcpResendPort resend, IPAddress localAddress public GevStreamStats Stats => _stats; + /// + /// 스트림이 프레임을 받고 있으면 참 — 가 성공한 뒤부터, · + /// 가 불리거나 수신 스레드가 스스로 끝날 때(스트림 소켓이 죽는 등)까지. + /// + /// 수신 스레드가 스스로 끝나면 이 값이 거짓이 되고 는 큐에 남은 장을 다 내준 뒤 + /// 으로 끝난다. 그 스트림은 다시 시작할 수 없고, 정리도 아직이다 — + /// (또는 )를 불러야 장치 전송이 꺼지고 버퍼가 돌아온다. + /// 장치가 조용해진 것만으로는 수신 스레드가 끝나지 않으므로 그때는 참으로 남는다( 설명 참고). + /// + /// public bool IsStarted => Volatile.Read(ref _state) == StateStarted; /// 프레임을 전달하지 못했을 때 수신 스레드에서 호출된다 — 가볍게 처리해야 한다. @@ -109,9 +124,12 @@ 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; @@ -172,8 +190,10 @@ 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")}."); } catch @@ -284,7 +304,8 @@ public async Task StopAsync(CancellationToken ct = default) } /// - /// 다음 프레임을 기다린다. 시작 전이거나 정지된 스트림이면 . + /// 다음 프레임을 기다린다. 시작 전이거나 정지된 스트림이면 — 수신 스레드가 + /// 스스로 끝난 스트림( 참고)도 큐에 남은 장을 다 내준 뒤 같은 예외로 끝난다. /// 받은 프레임은 반드시 Dispose 한다. /// /// 장치가 사라져도 이 대기는 스스로 끝나지 않는다. 장치는 자기가 연 스트림을 모르므로 제어 상실 @@ -539,6 +560,12 @@ private bool SendPunch(Socket socket) /// internal void SimulateReceiverQueueCompletion(Exception cause) => _queue?.Complete(cause); + /// + /// 정지 요청 없이 스트림 소켓을 닫는다 — NIC 가 내려가 소켓이 죽은 것과 같은 자리를 만들어, 수신 스레드가 + /// 스스로 끝나는 실제 경로(수신 오류 → 루프 종료 → 큐 닫기)를 밟게 한다. + /// + internal void KillSocketForTest() => _socket?.Close(); + /// 조립이 끝나 큐에 든 프레임을 동기로 꺼낸다 — 할당 계측이 비동기 대기의 할당에 섞이지 않게. internal bool TryDrainForTest(out GevFrame frame) { diff --git a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs index 76664ad..c3d8f38 100644 --- a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs +++ b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs @@ -475,6 +475,36 @@ public async Task StopWithAPreCancelledTokenStillStopsEverything() 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); + await Assert.ThrowsAsync(() => rig.Stream.ReceiveAsync(Ct).AsTask().WaitAsync(TimeSpan.FromSeconds(10), Ct)); + Assert.False(rig.Stream.IsStarted); + + // 스스로 끝난 스트림은 멈춘 것이 아니라 정리를 기다리는 것이다 — 다시 시작할 수는 없고, + // 정지를 불러야 장치 전송이 꺼지고 버퍼가 돌아온다. + 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() { From abc469315dcff8e089a0eceb8e255ec152882100 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:02:54 +0900 Subject: [PATCH 26/48] =?UTF-8?q?=EC=B2=AB=20=ED=8E=98=EC=9D=B4=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=A0=84=EC=97=90=20=EB=81=8A=EA=B8=B4=20=EB=B8=94?= =?UTF-8?q?=EB=A1=9D=EC=9D=84=20=EB=B3=B4=EC=A1=B4=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EA=B9=8C=EC=A7=80=20=EB=B6=99=EB=93=A4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B3=A0=20=EA=B3=A7=EB=B0=94=EB=A1=9C=20=EB=B6=88=EC=99=84?= =?UTF-8?q?=EC=A0=84=EC=9C=BC=EB=A1=9C=20=EB=8B=AB=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리더 뒤에 곧바로 패킷 id 1 의 트레일러가 오면(장치가 첫 페이로드를 보내기 전에 블록을 끊었다) 트레일러가 정한 페이로드 수는 0 이다. 끊긴 블록 판정(IsCutShort)이 "패킷 수 > 0" 을 요구해 이 0 을 "아직 모름" 으로 읽었고, 그래서 프레임은 보존 시간 내내 뒤 프레임을 막은 채 기다렸으며, 끊긴 블록 경고도 나가지 않았고, 불완전 프레임을 받겠다고 한 소비자에게도 끝내 전달되지 않았다(전달 조건도 같은 "패킷 수 > 0" 이었다). 트레일러가 정한 0 은 확정된 값이므로 IsCutShort 에서 그 조건을 뺀다 — 한 패킷이라도 받은 뒤 끊긴 블록과 같은 경로(즉시 닫기, 경고 한 번, 불완전 통계, FrameDropped)를 탄다. 전달 조건은 트레일러가 있고 리더가 크기를 알린 경우에도 열어, 다른 끊긴 블록처럼 전부 0 으로 채운 불완전 프레임으로 나간다. 청크가 붙어 크기를 모르는 프레임은 그대로 보존 시간 경로에 남는다. 회귀: BlockCutBeforeItsFirstPayloadClosesAtOnceAsIncomplete(보존 시간 30 초에서 3 초 안에 0 으로 채운 불완전 프레임이 나오는지 — 고치기 전 시한 초과), BlockCutBeforeItsFirstPayloadIsReportedLikeAnyCutBlock(경고 한 줄 — 고치기 전 FrameDropped 조차 없어 시한 초과). 로그 테스트는 전역 싱크를 바꾸므로 격리 컬렉션의 새 클래스에 둔다. --- src/GevSharp/Gvsp/GevStream.Receiver.cs | 12 ++- tests/GevSharp.Tests/Gvsp/GevStreamTests.cs | 89 ++++++++++++++++++++- 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/src/GevSharp/Gvsp/GevStream.Receiver.cs b/src/GevSharp/Gvsp/GevStream.Receiver.cs index 3e8f15a..de7f88c 100644 --- a/src/GevSharp/Gvsp/GevStream.Receiver.cs +++ b/src/GevSharp/Gvsp/GevStream.Receiver.cs @@ -1352,9 +1352,13 @@ 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; /// @@ -1430,7 +1434,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/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs index c3d8f38..5d29624 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; @@ -1321,11 +1322,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); @@ -1414,6 +1416,39 @@ 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 LeaderRecoveredAfterAShorterTrailerStillShrinksTheFrame() { @@ -1543,3 +1578,55 @@ 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)")); + } +} From 3cbf71db900a44360213dd8c00f0eda6379acade Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:06:18 +0900 Subject: [PATCH 27/48] =?UTF-8?q?=EC=9D=B4=EB=AF=B8=20=EC=8B=A4=EC=9D=80?= =?UTF-8?q?=20=EB=92=A4=EC=97=90=20=ED=8C=A8=ED=82=B7=20=EA=B0=84=EA=B2=A9?= =?UTF-8?q?=EC=9D=B4=20=EB=B0=94=EB=80=90=20=ED=94=84=EB=A0=88=EC=9E=84?= =?UTF-8?q?=EC=9D=84=20=EC=99=84=EC=84=B1=EC=9C=BC=EB=A1=9C=20=EB=82=B4?= =?UTF-8?q?=EB=B3=B4=EB=82=B4=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=EB=A1=9C=20=EB=B2=84=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 페이로드는 (id-1) x 패킷당 바이트 자리에 싣는다. 리더와 첫 페이로드(id 1)가 함께 유실되면 간격을 배울 근거가 없어 협상값(SCPS)에서 구한 간격으로 먼저 싣고, 나중에 리센드로 돌아온 id 1 이 진짜 간격을 알려 준다. 그때는 이미 실은 id 2 이상이 틀린 자리에 있다. 받은 패킷 수는 다 차고, 받은 끝(ReceivedEnd)은 덮은 범위가 아니라 가장 먼 끝이라 간격이 줄면 오히려 리더 크기를 넘으므로, 어긋난 바이트와 그 사이에 남은 이전 프레임 바이트가 IsComplete=true 로 나갔다. 반대로 협상값보다 긴 패킷을 보내는 장치에서 짧은 마지막 패킷이 먼저 실리면, 크기를 모르는 청크 프레임은 패킷 수로만 완성을 가리므로 청크 꼬리를 잃은 프레임(3600 바이트 중 2964)이 완성으로 나갔다. 이미 실은 바이트는 옮기지 않는다. 대신 id 2 이상을 실은 뒤에 간격이 바뀌면(줄든 늘든) SetDataBytes 가 그 프레임을 Error(코드 LocalProblem — 자리를 짐작한 것은 수신기다)로 버리고 스트림당 한 번 경고한다. id 1 만 실었을 때나 아무것도 싣기 전에 배우는 정상 경로(리더가 함께 오는 프레임, 협상값보다 긴 패킷이 먼저 오는 경우)는 그대로다. 회귀 두 건: - PacketStrideThatShrinksAfterBytesWereLaidDropsTheFrameAsError — 더럽힌 버퍼, 리더·id 1 유실, 짧은 패킷. 고치기 전 "block 2 was delivered complete with its bytes laid at the wrong packet stride". 이어서 리더가 함께 오는 같은 짧은 패킷의 프레임이 완성되는지도 본다. - PacketStrideThatGrowsAfterBytesWereLaidDropsTheChunkFrameAsError — 청크 프레임, id 1 유실, 긴 패킷. 고치기 전 "delivered complete with 2964 of 3600 bytes". --- src/GevSharp/Gvsp/GevStream.Receiver.cs | 34 ++++++++++ tests/GevSharp.Tests/Gvsp/GevStreamTests.cs | 75 +++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/src/GevSharp/Gvsp/GevStream.Receiver.cs b/src/GevSharp/Gvsp/GevStream.Receiver.cs index de7f88c..df66fc1 100644 --- a/src/GevSharp/Gvsp/GevStream.Receiver.cs +++ b/src/GevSharp/Gvsp/GevStream.Receiver.cs @@ -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]; @@ -257,6 +258,7 @@ private void InitReceiver(int packetSize) _hasLoggedPayloadCeiling = false; _hasLoggedShortLeader = false; _hasLoggedShortBlock = false; + _hasLoggedStrideChange = false; _punchIntervalTicks = _opt.FirewallTraversal && _opt.FirewallTraversalIntervalMs > 0 ? MsToTicks(_opt.FirewallTraversalIntervalMs) : 0; @@ -870,6 +872,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) { @@ -913,8 +916,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}."); @@ -1380,6 +1397,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]!; diff --git a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs index 5d29624..70b0b48 100644 --- a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs +++ b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs @@ -1449,6 +1449,81 @@ public async Task BlockCutBeforeItsFirstPayloadClosesAtOnceAsIncomplete() } } + [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() { From 45454f5cbd8663e6ef5bede49ef7708bfd59aaad Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:09:04 +0900 Subject: [PATCH 28/48] =?UTF-8?q?=EB=A6=AC=EB=8D=94=EB=A7=8C=20=EB=B0=9B?= =?UTF-8?q?=EC=9D=80=20=ED=94=84=EB=A0=88=EC=9E=84=20=EC=9C=84=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EB=B8=94=EB=A1=9D=20=EB=B2=88=ED=98=B8?= =?UTF-8?q?=EC=9D=98=20=EC=83=88=20=EC=B4=AC=EC=98=81=EC=9D=B4=20=EC=98=A4?= =?UTF-8?q?=EB=A9=B4=20=EC=98=9B=20=EB=A6=AC=EB=8D=94=EB=A1=9C=20=EC=A1=B0?= =?UTF-8?q?=EB=A6=BD=ED=95=98=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=EC=8A=AC?= =?UTF-8?q?=EB=A1=AF=EC=9D=84=20=EB=8B=A4=EC=8B=9C=20=EC=97=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 노출이 긴 촬영에서는 리더가 먼저 오므로, 리더만 받은 가장 새 프레임은 보존 시간(FrameRetentionMs)으로도 닫지 않는다. 그 사이 장치가 그 블록을 버리고(정지) 촬영을 다시 시작해 같은 블록 번호로 새 리더를 보내면 — 촬영마다 번호를 1 부터 다시 세는 장치가 있다 — 슬롯은 블록 번호로만 찾으므로 새 리더가 중복으로 버려지고, 새 페이로드가 옛 리더의 슬롯에 실려 옛 타임스탬프·기하로 완성 처리됐다. 바이트 수가 같으면 IsComplete=true 로 나와 틀린 값이 정상처럼 보인다. 리더만 받은 프레임을 보존 시간으로 닫는 쪽으로는 고치지 않는다 — 그 예외가 긴 노출 장치를 위해 있다. 대신 옛 프레임에 페이로드도 트레일러도 없고, 재요청 간격(PacketTimeoutMs) 이상 조용했으며, 새 리더가 리센드 사본이 아니고 타임스탬프가 옛 리더와 같지 않으면(같으면 늦은 사본이다) 장치가 블록을 다시 시작한 것으로 보고 같은 슬롯을 새 리더로 다시 연다. 옛 프레임은 불완전 한 장으로 세고 FrameDropped 로 알리되, 받은 이미지 바이트가 없고 더 오래된 조립 중 프레임을 앞지르면 안 되므로 DeliverIncompleteFrames 여도 내보내지 않는다(옵션 문서에 적었다). 닫았다 새로 열지 않고 제자리에서 다시 쓰는 것은 닫힌 블록 기록에 옛 타임스탬프를 남겨 새 프레임의 늦은 사본을 새 프레임으로 오인하지 않기 위해서다. 재요청 간격 안에 다시 시작한 리더는 여전히 중복으로 버려진다. 회귀: RestartedBlockAfterALeaderOnlyFrameIsAssembledWithItsOwnLeader(64x100 리더 뒤 보존 시간을 넘겨 쉬고 같은 번호의 128x50 프레임 — 고치기 전 Timestamp 기대 222000, 실제 111000), LateCopyOfALeaderOnlyFramesLeaderIsStillADuplicate (같은 타임스탬프의 늦은 사본은 다시 열지 않는다 — 새 규칙의 반대편을 못 박는다). architecture.md 의 블록 번호 재시작 절에 이 경우를 적었다. --- docs/architecture.md | 6 +- src/GevSharp/Gvsp/GevStream.Receiver.cs | 46 +++++++++++++- src/GevSharp/Gvsp/GevStreamOpt.cs | 6 +- tests/GevSharp.Tests/Gvsp/GevStreamTests.cs | 66 +++++++++++++++++++++ 4 files changed, 121 insertions(+), 3 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index d7852c4..eb48dc8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -400,7 +400,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 diff --git a/src/GevSharp/Gvsp/GevStream.Receiver.cs b/src/GevSharp/Gvsp/GevStream.Receiver.cs index df66fc1..2bd26cd 100644 --- a/src/GevSharp/Gvsp/GevStream.Receiver.cs +++ b/src/GevSharp/Gvsp/GevStream.Receiver.cs @@ -474,6 +474,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; @@ -578,6 +579,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; @@ -1324,7 +1367,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) { diff --git a/src/GevSharp/Gvsp/GevStreamOpt.cs b/src/GevSharp/Gvsp/GevStreamOpt.cs index 6b9b62b..91cfa5e 100644 --- a/src/GevSharp/Gvsp/GevStreamOpt.cs +++ b/src/GevSharp/Gvsp/GevStreamOpt.cs @@ -39,7 +39,11 @@ public sealed class GevStreamOpt /// public double PacketRequestRatio { get; set; } = 0.25; - /// 참이면 포기한 프레임도 = false 로 전달한다(빠진 영역은 0 으로 채운다). 기본은 버리고 세기만 한다. + /// + /// 참이면 포기한 프레임도 = false 로 전달한다(빠진 영역은 0 으로 채운다). 기본은 버리고 세기만 한다. + /// 하나 예외: 리더만 받은 프레임을 장치가 버리고 같은 블록 번호로 촬영을 다시 시작하면, 받은 이미지 바이트가 없는 옛 프레임은 + /// 전달하지 않고 불완전 통계와 로만 알린다. + /// public bool DeliverIncompleteFrames { get; set; } = false; /// diff --git a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs index 70b0b48..54cd5de 100644 --- a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs +++ b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs @@ -1043,6 +1043,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() { From b1abad861498443b24b48681704a8e11f3a73d1b Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:11:34 +0900 Subject: [PATCH 29/48] =?UTF-8?q?=ED=8A=B8=EB=A0=88=EC=9D=BC=EB=9F=AC?= =?UTF-8?q?=EB=A5=BC=20=EC=9E=83=EC=9D=80=20=EC=B1=84=20=EB=B0=94=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EA=B0=80=20=EB=AA=A8=EC=9E=90=EB=9E=80=20=ED=94=84?= =?UTF-8?q?=EB=A0=88=EC=9E=84=EC=9D=B4=20=EB=B3=B4=EC=A1=B4=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EA=B9=8C=EC=A7=80=20=EA=B8=B0=EB=8B=A4=EB=A6=AC?= =?UTF-8?q?=EB=8A=94=20=EA=B9=8C=EB=8B=AD=EC=9D=84=20=EC=A0=81=EB=8A=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리더가 알린 패킷을 다 받았는데 받은 끝이 리더 크기에 못 미치고(크기 규칙이 장치보다 크게 센 경우 — 실기에서는 본 적 없다) 트레일러까지 잃으면, 끊긴 블록 판정은 트레일러를 요구하므로 프레임은 보존 시간까지 기다린 뒤 불완전으로 닫힌다. 동작은 바꾸지 않는다. 늦게 온 트레일러가 가변 높이의 실제 줄 수를 알리면 ApplyTrailerHeight 가 크기를 줄여 그 프레임을 완성시킬 수 있으므로, 침묵만으로 일찍 닫으면 살릴 수 있던 프레임을 버리게 된다. 트레일러는 리센드로 묻지 않으므로 끝내 안 오면 결과는 어차피 불완전이고 달라지는 것은 닫히는 시각뿐이다 — 어느 쪽이든 모자란 프레임을 완성으로 내보내지는 않는다. 이 판단을 IsCutShort 옆에 남겨 "중복 대기" 로 보고 지우지 않게 한다. --- src/GevSharp/Gvsp/GevStream.Receiver.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/GevSharp/Gvsp/GevStream.Receiver.cs b/src/GevSharp/Gvsp/GevStream.Receiver.cs index 2bd26cd..29aa4ae 100644 --- a/src/GevSharp/Gvsp/GevStream.Receiver.cs +++ b/src/GevSharp/Gvsp/GevStream.Receiver.cs @@ -1417,6 +1417,13 @@ private static bool IsComplete(FrameSlot slot) /// 트레일러가 약속한 패킷은 다 받았는데 리더가 알린 바이트에 못 미친다 — 장치가 블록을 끊었고 더 올 것이 없다. /// 트레일러가 id 1 로 왔으면(첫 페이로드 전에 끊겼다) 약속한 패킷 수는 0 이고 그것도 끊긴 블록이다 — 트레일러가 정한 0 은 /// "아직 모름" 이 아니므로 보존 시간까지 기다릴 까닭이 없다. + /// + /// 트레일러를 잃은 프레임은 여기에 걸리지 않고 보존 시간(리센드가 꺼져 있으면 재요청 간격)까지 기다린다 — 리더가 알린 패킷을 다 받았는데 바이트만 모자라더라도 + /// (크기 규칙이 장치보다 크게 셌을 때. 실기에서는 본 적 없다) 그렇다. 늦게 온 트레일러가 가변 높이의 실제 줄 수를 알리면 + /// 가 크기를 줄여 완성시킬 수 있으므로, 침묵만으로 일찍 닫으면 살릴 수 있던 프레임을 버린다. + /// 트레일러는 리센드로 묻지 않으므로( 는 예상 패킷 수까지만 훑는다) 끝내 안 오면 결과는 어차피 불완전이고, + /// 달라지는 것은 닫히는 시각(최대 보존 시간)뿐이다 — 어느 쪽이든 모자란 프레임을 완성으로 내보내지는 않는다. + /// /// private static bool IsCutShort(FrameSlot slot) => slot.HasLeader && slot.HasTrailer && slot.Buf is not null && slot.ReceivedPayloads >= slot.ExpectedPackets From 33bfca102c738910b6c038fefae0a24e1e0e4002 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:11:35 +0900 Subject: [PATCH 30/48] =?UTF-8?q?=EB=A6=AC=EC=84=BC=EB=93=9C=EA=B0=80=20?= =?UTF-8?q?=EA=BA=BC=EC=A7=80=EB=A9=B4=20=EB=B3=B4=EC=A1=B4=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EC=9D=B4=20=EC=93=B0=EC=9D=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=EB=8B=A4=EB=8A=94=20=EA=B2=83=EA=B3=BC=20=EC=88=98?= =?UTF-8?q?=EC=8B=A0=20=EB=A3=A8=ED=94=84=EC=9D=98=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EC=A0=90=EA=B2=80=20=EA=B0=84=EA=B2=A9=EC=9D=84=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=EC=99=80=20=EC=8B=9C=EC=9E=91=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=97=90=20=EC=A0=81=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResendEnabled = false 이거나 PacketRequestRatio = 0 이면 수신기는 리센드를 요청하지 않고, 불완전 프레임을 마지막 패킷 뒤 PacketTimeoutMs 에 포기하며 더 새로운 블록이 시작되면 곧바로 닫는다 — FrameRetentionMs 는 쓰이지 않는다. 동작은 그대로 두고, 옵션 문서(ResendEnabled·PacketTimeoutMs·FrameRetentionMs·PacketRequestRatio)와 architecture.md 의 옵션 목록에 적는다. 비율 0 은 ResendEnabled = false 와 같고, 0 보다 크면 아무리 작아도 프레임마다 한 패킷은 요청할 수 있다는 불연속도 적는다. FrameRetentionMs 문서에는 더 요청할 것이 없는 프레임이 PacketTimeoutMs 에 닫힌다는 것과, 리더만 받은 가장 새 프레임이 보존 시간의 예외라는 것도 함께 적는다. 시작할 때 리센드가 꺼져 있으면 Info 로그 한 줄로 알린다 — 옵션만 보고 보존 시간을 늘린 사람이 로그에서 이유를 찾게. 시작 줄의 "resend on/off" 는 ResendEnabled 옵션 하나가 아니라 실제로 도는 쪽을 말하도록 고친다(비율 0 에서 "on" 이라고 적고 있었다). 비율은 문화권과 무관하게 찍는다. PacketTimeoutMs 문서는 이것이 수신 루프의 점검 간격이라고 했는데, 실제로 루프는 조립 중인 프레임이 있으면 max(1, min(InitialPacketTimeoutMs, PacketTimeoutMs)) ms, 없으면 200 ms 마다 깨어난다. 그렇게 고치고, 이 값이 침묵으로 꼬리를 확정하는 문턱이자 더 요청할 것이 없는 프레임의 포기 시각이라는 것도 적는다. 회귀: StartSaysWhenFrameRetentionDoesNotApply(리센드 켬 / ResendEnabled 끔 / 비율 0 — 알림 줄 개수와 시작 줄의 on/off). 전역 로그 싱크를 바꾸므로 격리 컬렉션의 GevStreamLogTests 에 둔다. --- docs/architecture.md | 11 ++++++---- src/GevSharp/Gvsp/GevStream.cs | 9 +++++++- src/GevSharp/Gvsp/GevStreamOpt.cs | 22 ++++++++++++++++++-- tests/GevSharp.Tests/Gvsp/GevStreamTests.cs | 23 +++++++++++++++++++++ 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index eb48dc8..263a4cc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -293,11 +293,14 @@ 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; + // with resend off an incomplete frame goes after PacketTimeoutMs, or at once when a newer block starts + 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) diff --git a/src/GevSharp/Gvsp/GevStream.cs b/src/GevSharp/Gvsp/GevStream.cs index ad0f385..3618b70 100644 --- a/src/GevSharp/Gvsp/GevStream.cs +++ b/src/GevSharp/Gvsp/GevStream.cs @@ -194,7 +194,14 @@ public async Task StartAsync(CancellationToken ct = default) // 내리는데, 그보다 늦게 여기서 "시작됨" 을 쓰면 죽은 스트림이 시작된 것으로 남는다. 띄우기가 던지면 아래 catch 가 되돌린다. Volatile.Write(ref _state, StateStarted); thread.Start(); - 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 { diff --git a/src/GevSharp/Gvsp/GevStreamOpt.cs b/src/GevSharp/Gvsp/GevStreamOpt.cs index 91cfa5e..33ac8b3 100644 --- a/src/GevSharp/Gvsp/GevStreamOpt.cs +++ b/src/GevSharp/Gvsp/GevStreamOpt.cs @@ -20,15 +20,31 @@ 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,6 +52,8 @@ public sealed class GevStreamOpt /// 같은 구멍을 다시 묻는 재요청은 이 상한을 쓰지 않는다 — 재요청은 간격으로 까지만 되풀이되므로 그 자체로 유한하다. /// 장치가 프레임 도중 만큼 쉬어 꼬리를 침묵으로 짐작한 경우에는 이 상한을 넘겨도 프레임을 포기하지 않는다 — /// 상한 안에 들어가는 앞부분만 묻고, 장치가 이어 보내면 프레임은 그대로 완성된다. + /// 0 은 리센드를 끈다( = false 와 같고 도 쓰이지 않는다). 0 보다 크면 아무리 작아도 + /// 프레임마다 적어도 한 패킷은 요청할 수 있다. /// public double PacketRequestRatio { get; set; } = 0.25; diff --git a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs index 54cd5de..be7e4c3 100644 --- a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs +++ b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs @@ -1770,4 +1770,27 @@ public async Task BlockCutBeforeItsFirstPayloadIsReportedLikeAnyCutBlock() 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.")); + } } From e18e1ea1ccdcdea750e890fb0cba077b7171fc3d Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 14:30:25 +0900 Subject: [PATCH 31/48] =?UTF-8?q?GevDevice.OpenAsync(IPEndPoint)=20?= =?UTF-8?q?=EB=A5=BC=20=EA=B3=B5=EA=B0=9C=ED=95=B4=20=ED=91=9C=EC=A4=80=20?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=EA=B0=80=20=EC=95=84=EB=8B=8C=20=EC=9E=A5?= =?UTF-8?q?=EC=B9=98=EB=A5=BC=20=EC=97=B4=20=EC=88=98=20=EC=9E=88=EA=B2=8C?= =?UTF-8?q?=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지금까지 장치는 표준 GVCP 포트 3956 으로만 열 수 있었고, 포트를 지정하는 오버로드는 시뮬레이터 시험용 내부 멤버였다. 그래서 루프백의 시뮬레이터나 포트를 옮겨 둔 NAT·포워딩 뒤의 장치를 소비자가 공개 API 로 열 길이 없었다(하류가 장비 없는 시험을 위해 요청). 그 오버로드를 공개한다 — 공개 API 추가라 다음 판은 minor 다. 포트는 제어 채널(레지스터 접근·하트비트·리센드 요청)에만 쓰이고 스트림은 장치가 자기 설정대로 보내는 곳에서 받는다는 것을 문서에 적었다. 포트 0 은 ArgumentOutOfRangeException. ProbeAsync(IPEndPoint) 는 내부로 둔다. 테스트: 오버로드가 공개인지, 임시 포트의 시뮬레이터를 이 오버로드로 열어 레지스터를 읽는지, 포트 0 거절. architecture.md 의 API 목록과 '내부 오버로드' 서술을 고쳤다. --- docs/architecture.md | 9 ++++--- src/GevSharp/GevDevice.cs | 10 ++++++-- .../Integration/DeviceLifecycleTests.cs | 25 +++++++++++++++++++ 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 263a4cc..aa689cb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -181,6 +181,9 @@ 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; } @@ -725,7 +728,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 @@ -783,8 +786,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/src/GevSharp/GevDevice.cs b/src/GevSharp/GevDevice.cs index a19c902..838cacf 100644 --- a/src/GevSharp/GevDevice.cs +++ b/src/GevSharp/GevDevice.cs @@ -112,10 +112,16 @@ public static Task OpenAsync(IPAddress address, GevDeviceOpt? opt = n 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 만 받는다. + /// 이 포트는 제어 채널(레지스터 접근·하트비트·리센드 요청)에만 쓰인다 — 스트림은 장치가 자기 설정대로 보내는 곳에서 받는다. + /// + /// 포트가 0 이다. + 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); } diff --git a/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs b/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs index f20d0bc..5f7bd65 100644 --- a/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs +++ b/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs @@ -512,6 +512,31 @@ 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); + } + // ---------------------------------------------------------------- stream vs. device lifetime [Fact] From 78619b021a42cf235f0d35af61d89600d455ddb1 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 15:15:08 +0900 Subject: [PATCH 32/48] =?UTF-8?q?PENDING=5FACK=20=EC=97=B0=EC=9E=A5?= =?UTF-8?q?=EC=9D=84=20=EB=8B=A4=20=EC=93=B4=20=EC=8B=9C=ED=95=9C=20?= =?UTF-8?q?=EC=B4=88=EA=B3=BC=EB=A5=BC=20XML=20=EC=A0=81=EC=9E=AC=EA=B0=80?= =?UTF-8?q?=20=EC=9E=A5=EC=B9=98=20=EC=83=81=EC=8B=A4=EB=A1=9C=20=EC=9D=BD?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=ED=91=9C=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=80=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GVCP 채널은 두 가지 시한 초과를 같은 GevTimeoutException 으로 낸다 — 재전송까지 응답이 아예 없던 것과, 장치가 PENDING_ACK 로 "받아서 실행 중" 이라고 답한 뒤 허락된 연장 안에 끝내지 못한 것. XML 적재의 장치 상실 판정은 형만 봐서 뒤의 것도 상실로 읽었다. 그러면 First URL 의 READMEM 이 PENDING_ACK 뒤에 멈췄을 때 Second URL 을 시도하지 않고 "다시 연결하라" 는 뜻의 맨 GevTimeoutException 을 던진다. 장치는 살아 있고 다시 연결해도 같은 First URL 이 같은 자리에서 멈출 뿐이라, 끝날 수 있는 Second URL 을 버리는 셈이다. - GvcpChannel.RequestAsync 가 PENDING_ACK 연장을 다 써 던지는 시한 초과의 Data 에 내부 키 PendingAckExpired 로 true 를 단다(FailedIndexKey 와 같은 방식, 공개 API 추가 없음). - GevXmlLoader.IsDeviceLoss 가 그 표식이 붙은 시한 초과를 상실에서 뺀다. URL 레지스터 읽기에서 나면 다른 URL 로 넘어가고, Local: 메모리 읽기에서 나면 다른 읽기 실패처럼 파일 이름을 붙여 감싼 뒤 다른 URL 로 넘어간다. - 루프백 장치로 여는 시험: First URL 의 XML 읽기에 PENDING_ACK 만 보내고 멈추면 Second URL 의 XML 이 나온다 (고치기 전에는 GetXmlAsync 가 GevTimeoutException 으로 끝났다). 채널 시험에는 표식이 연장을 다 쓴 쪽에만 붙고 무응답 시한 초과에는 없다는 확인을 더했다. - GevTimeoutException·LoadAsync·LoadFromUrlAsync 문서와 architecture.md 의 XML 적재 규칙에 이 경우를 적었다. --- docs/architecture.md | 10 ++-- src/GevSharp/GevException.cs | 3 +- src/GevSharp/Gvcp/GvcpChannel.cs | 14 ++++- src/GevSharp/Xml/GevXmlLoader.cs | 10 +++- tests/GevSharp.Tests/Gvcp/GvcpChannelTests.cs | 6 +- tests/GevSharp.Tests/Xml/GevXmlLoaderTests.cs | 59 +++++++++++++++++++ 6 files changed, 93 insertions(+), 9 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index aa689cb..2e3de4f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -513,10 +513,12 @@ Read First URL (512 bytes) then Second URL as fallback. `Local:` addresses/lengt 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 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. An http download timeout is not -device loss and still falls back. 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`. +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 channel marks that timeout). 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`. Cache file name: `{Manufacturer}_{Model}_{DeviceVersion}_{FileName}` sanitized; cache is opt-in. ### GenApi (`GevSharp.GenApi`) diff --git a/src/GevSharp/GevException.cs b/src/GevSharp/GevException.cs index 979021c..32a5b4c 100644 --- a/src/GevSharp/GevException.cs +++ b/src/GevSharp/GevException.cs @@ -13,7 +13,8 @@ public GevException(string message, Exception? inner) : base(message, inner) { } /// GVCP 요청이 재전송까지 전부 응답 없이 끝났다. 장치가 명령을 받았는지는 알 수 없다. /// 장치가 PENDING_ACK 로 "받아서 실행 중" 이라고 답한 뒤 허락된 연장(, /// ) 안에 끝내지 못했다. 명령이 이미 장치에 있으므로 라이브러리는 재전송하지 않는다 — -/// 장치가 그 명령을 실행했을 수 있으니, 호출자가 같은 명령을 다시 보내면 두 번 실행될 수 있다. +/// 장치가 그 명령을 실행했을 수 있으니, 호출자가 같은 명령을 다시 보내면 두 번 실행될 수 있다. 장치는 살아 있으므로 카메라 XML 적재는 +/// 이 경우를 장치 상실로 보지 않고 다른 URL 로 넘어간다. /// 카메라 XML 을 HTTP 로 받다가 를 넘겼다(GVCP 와 무관). GevXmlLoader.LoadFromUrlAsync 는 /// 이 예외를 그대로 던지고, First/Second URL 을 차례로 시도하는 GevXmlLoader.LoadAsync· 에서는 /// 두 URL 의 실패를 모은 의 으로 실려 올 수 있다. diff --git a/src/GevSharp/Gvcp/GvcpChannel.cs b/src/GevSharp/Gvcp/GvcpChannel.cs index 2e4f151..e721b3d 100644 --- a/src/GevSharp/Gvcp/GvcpChannel.cs +++ b/src/GevSharp/Gvcp/GvcpChannel.cs @@ -53,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); @@ -194,9 +201,14 @@ public async Task RequestAsync(GvcpCmd cmd, CancellationToken ct = defa // 같은 명령을 또 보내면 두 번 실행될 수 있다. 재시도마다 연장 예산이 다시 붙어 줄을 붙드는 시간이 // (1 + Retries) 배로 늘어나는 것도 여기서 끊는다 — 하트비트가 그 줄에 같이 서 있다. if (Interlocked.Read(ref pending.PendingDeadlineMs) > 0) - throw new GevTimeoutException( + { + var expired = new GevTimeoutException( $"{cmd.Name} to {DeviceEndPoint} 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})"); diff --git a/src/GevSharp/Xml/GevXmlLoader.cs b/src/GevSharp/Xml/GevXmlLoader.cs index 74e0419..0a89f12 100644 --- a/src/GevSharp/Xml/GevXmlLoader.cs +++ b/src/GevSharp/Xml/GevXmlLoader.cs @@ -46,7 +46,8 @@ public static class GevXmlLoader /// 포트가 장치를 잃었다고 알리면(, , /// — URL 레지스터 읽기나 Local: 메모리 읽기에서) 다른 URL 로 넘어가지 않고 그 예외를 /// 감싸지 않은 채 그대로 던진다(다른 URL 도 같은 포트를 거쳐 재시도 예산만 한 번 더 쓴다). 두 번째 시도에서 잃었어도 같다. - /// http 내려받기의 시한 초과는 장치가 아니라 서버 쪽 사정이라 여기에 들지 않는다 — 다른 URL 로 넘어간다. + /// 시한 초과라도 상대가 살아 있던 것은 여기에 들지 않는다 — 다른 URL 로 넘어간다: http 내려받기의 시한 초과(장치가 아니라 서버 쪽 사정), + /// 장치가 PENDING_ACK 로 "받아서 실행 중" 이라고 답한 뒤 허락된 연장 안에 끝내지 못한 읽기. /// 그 밖에 시도한 URL 이 모두 같은 구체 형(예: 둘 다 )으로 실패했으면 첫 실패를 그대로 던지고, /// 종류가 다르면(빈 레지스터 포함) 두 사유를 모두 실은 (마지막 실패가 InnerException). /// 취소는 그대로 전파된다. @@ -152,11 +153,15 @@ internal static async Task LoadAsync(IGevPort port, string? cacheDir, /// 포트가 장치를 잃었다는 실패인지 — 제어 상실, 응답 없는 시한 초과, 해제된 장치. 이런 실패 뒤에는 다른 URL 도 같은 포트를 거쳐 /// 같은 이유로 실패하므로 넘어가지 않고 원래 형 그대로 던진다. fetchKind 는 가져오기 단계의 URL 종류(URL 레지스터 읽기 단계면 null) — /// http 내려받기의 시한 초과는 서버 쪽 사정이라 장치 상실이 아니다. + /// 장치가 PENDING_ACK 로 답한 뒤 연장 안에 끝내지 못한 시한 초과( 표식)도 상실이 아니다 — + /// 장치는 살아 있고 이 읽기만 못 끝났으니, 다른 URL 은 끝날 수 있다. /// internal static bool IsDeviceLoss(Exception ex, GevXmlUrlKind? fetchKind) => ex is GevControlLostException || ex is ObjectDisposedException - || (ex is GevTimeoutException && fetchKind != GevXmlUrlKind.Http); + || (ex is GevTimeoutException + && fetchKind != GevXmlUrlKind.Http + && ex.Data[GvcpChannel.PendingAckExpiredKey] is not true); // 예외가 하나 이상이고 전부 GevException 보다 구체적인 같은 형인지. private static bool IsSameSpecificKind(List errors) @@ -176,6 +181,7 @@ private static bool IsSameSpecificKind(List errors) /// 적중하면 XML 본문 전송 없이 캐시 텍스트를 돌려준다. 캐시 읽기·쓰기 실패는 경고 로그로만 남고 결과에는 영향이 없다. /// 가져오기 실패는 사유와 출처를 실은 이되, Local: 메모리 읽기 중 포트가 장치를 잃었다고 알린 /// ·· 은 감싸지 않고 그대로 던진다. + /// 장치가 PENDING_ACK 로 답한 뒤 연장 안에 끝내지 못한 메모리 읽기는 장치를 잃은 것이 아니라 다른 읽기 실패처럼 감싼다. /// http 내려받기가 시한을 넘기면 . /// public static Task LoadFromUrlAsync(IGevPort port, GevXmlUrl url, string? cacheDir = null, CancellationToken ct = default) diff --git a/tests/GevSharp.Tests/Gvcp/GvcpChannelTests.cs b/tests/GevSharp.Tests/Gvcp/GvcpChannelTests.cs index 2c1dd21..6e6535b 100644 --- a/tests/GevSharp.Tests/Gvcp/GvcpChannelTests.cs +++ b/tests/GevSharp.Tests/Gvcp/GvcpChannelTests.cs @@ -204,6 +204,8 @@ 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] @@ -314,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/Xml/GevXmlLoaderTests.cs b/tests/GevSharp.Tests/Xml/GevXmlLoaderTests.cs index 6f61ed3..b6460bc 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; @@ -385,6 +386,14 @@ private static FakeMemPort PortWithTwoLocalUrls() 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() { @@ -478,6 +487,9 @@ public void HttpTimeoutIsNotDeviceLossSoTheOtherUrlIsStillTried() Assert.False(GevXmlLoader.IsDeviceLoss(timeout, GevXmlUrlKind.Http)); Assert.True(GevXmlLoader.IsDeviceLoss(timeout, GevXmlUrlKind.Local)); Assert.True(GevXmlLoader.IsDeviceLoss(timeout, null)); // URL 레지스터 읽기 + // 장치가 PENDING_ACK 로 답한 뒤 연장을 다 쓴 시한 초과 — 장치는 살아 있다. 어느 단계에서 났든 상실이 아니다. + Assert.False(GevXmlLoader.IsDeviceLoss(PendingAckExpired(), GevXmlUrlKind.Local)); + Assert.False(GevXmlLoader.IsDeviceLoss(PendingAckExpired(), null)); Assert.True(GevXmlLoader.IsDeviceLoss(new GevControlLostException("lost"), null)); Assert.True(GevXmlLoader.IsDeviceLoss(new ObjectDisposedException("GevDevice"), GevXmlUrlKind.Local)); Assert.False(GevXmlLoader.IsDeviceLoss(new GevStatusException("READMEM", GvcpConst.StatusInvalidAddress), null)); @@ -502,6 +514,53 @@ public async Task FailuresOfDifferentKindsAreStillAggregated() 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); + } + // ---- ExtractXml ---- [Fact] From e7ffd9d917128f090b03ec136bf20d0f5bbfd31c Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 15:18:09 +0900 Subject: [PATCH 33/48] =?UTF-8?q?XML=20=EC=A0=81=EC=9E=AC=EA=B0=80=20?= =?UTF-8?q?=EC=9E=A5=EC=B9=98=20=EC=83=81=EC=8B=A4=EC=9D=B4=20=EC=95=84?= =?UTF-8?q?=EB=8B=8C=20=EC=8B=9C=ED=95=9C=20=EC=B4=88=EA=B3=BC=EB=A5=BC=20?= =?UTF-8?q?=EB=A7=A8=20GevTimeoutException=20=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=82=B4=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LoadAsync·GetXmlAsync 는 시도한 URL 이 모두 같은 구체 형으로 실패하면 첫 실패를 감싸지 않고 던진다. 한편 같은 메서드의 문서는 감싸지 않은 GevTimeoutException 을 "장치를 잃었다, 다시 연결하라" 로 읽으라고 한다. 이 두 규칙이 시한 초과에서 부딪쳤다. First URL 이 http 이고 서버가 연결만 받고 답하지 않으면(Second URL 이 같거나 역시 시한 초과) 내려받기 시한 초과가 그대로 나가, 호출자는 멀쩡한 장치를 끊고 다시 연결한 뒤 같은 서버에서 같은 시한 초과를 다시 겪는다. PENDING_ACK 연장을 다 쓴 읽기가 두 URL 에서 모두 난 경우도 같은 모양이었다. 장치 상실인 시한 초과는 그 자리에서 이미 감싸지 않고 던지므로, 이 규칙까지 내려온 시한 초과는 상대가 살아 있던 것뿐이다. - 같은 형이면 첫 실패를 그대로 던지는 규칙에서 GevTimeoutException 을 뺀다. 이제 두 URL 의 실패를 모은 GevException 으로 나가고, LoadAsync·GetXmlAsync 가 감싸지 않고 던지는 GevTimeoutException 은 언제나 응답 없는 GVCP 요청(장치 상실)이다. - 시험: 연결을 받고 답하지 않는 루프백 TCP 서버를 First/Second URL 로 두면 실제 시한(10 초) 뒤에 GevException 이 나오고 그 InnerException 이 내려받기 시한 초과다(고치기 전에는 맨 GevTimeoutException). 10 초가 드는 시험이라 다른 적재 시험 뒤에 줄 서지 않게 클래스를 따로 두었다. 두 URL 레지스터 읽기가 모두 PENDING_ACK 연장을 다 쓰는 경우도 빠른 시험으로 같은 규칙을 본다. - GevTimeoutException·GetXmlAsync·LoadAsync 문서와 architecture.md 의 XML 적재 규칙을 이 불변식에 맞췄다. GevTimeoutException 문서가 "InnerException 으로 실려 올 수 있다" 고만 하던 http 시한 초과 항목도 바로잡았다. --- docs/architecture.md | 3 +- src/GevSharp/GevDevice.Xml.cs | 6 +- src/GevSharp/GevException.cs | 6 +- src/GevSharp/Xml/GevXmlLoader.cs | 9 +- .../Xml/GevXmlLoaderHttpTimeoutTests.cs | 82 +++++++++++++++++++ tests/GevSharp.Tests/Xml/GevXmlLoaderTests.cs | 19 +++++ 6 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 tests/GevSharp.Tests/Xml/GevXmlLoaderHttpTimeoutTests.cs diff --git a/docs/architecture.md b/docs/architecture.md index 2e3de4f..6adec71 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -518,7 +518,8 @@ alive is not device loss and still falls back: an http download timeout, and a r PENDING_ACK but did not finish within the allowed extension (the channel marks that timeout). 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`. +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`) diff --git a/src/GevSharp/GevDevice.Xml.cs b/src/GevSharp/GevDevice.Xml.cs index e515573..8a4a754 100644 --- a/src/GevSharp/GevDevice.Xml.cs +++ b/src/GevSharp/GevDevice.Xml.cs @@ -13,8 +13,10 @@ public sealed partial class GevDevice /// /// 적재 중 장치를 잃으면(, 응답 없는 , /// 해제된 장치의 ) Second URL 로 넘어가지 않고 그 예외를 그대로 던진다 — 다시 연결할 일이다. - /// 두 URL 이 같은 종류로 실패하면 그 예외를, 다른 종류로 실패하면 두 사유를 담은 을 던진다 - /// (규칙 전체는 ). + /// 여기서 감싸지 않은 채 나오는 은 언제나 이 경우다. 상대가 답은 한 시한 초과(http 내려받기의 시한 초과, + /// 장치가 PENDING_ACK 로 답한 뒤 연장 안에 못 끝낸 읽기)는 Second URL 로 넘어가고, 두 URL 이 모두 그렇게 실패해도 + /// 으로 감싸 낸다. 그 밖에 두 URL 이 같은 종류로 실패하면 그 예외를, 다른 종류로 실패하면 두 사유를 담은 + /// 을 던진다(규칙 전체는 ). /// /// public async Task GetXmlAsync(CancellationToken ct = default) diff --git a/src/GevSharp/GevException.cs b/src/GevSharp/GevException.cs index 32a5b4c..7c73f8d 100644 --- a/src/GevSharp/GevException.cs +++ b/src/GevSharp/GevException.cs @@ -16,9 +16,11 @@ public GevException(string message, Exception? inner) : base(message, inner) { } /// 장치가 그 명령을 실행했을 수 있으니, 호출자가 같은 명령을 다시 보내면 두 번 실행될 수 있다. 장치는 살아 있으므로 카메라 XML 적재는 /// 이 경우를 장치 상실로 보지 않고 다른 URL 로 넘어간다. /// 카메라 XML 을 HTTP 로 받다가 를 넘겼다(GVCP 와 무관). GevXmlLoader.LoadFromUrlAsync 는 -/// 이 예외를 그대로 던지고, First/Second URL 을 차례로 시도하는 GevXmlLoader.LoadAsync· 에서는 -/// 두 URL 의 실패를 모은 의 으로 실려 올 수 있다. +/// 이 예외를 그대로 던진다. /// +/// First/Second URL 을 차례로 시도하는 GevXmlLoader.LoadAsync· 는 둘째·셋째 경우를 감싸지 않은 채 +/// 내지 않는다 — 두 URL 의 실패를 모은 으로 내고, 이 예외는 그 메시지에(마지막 실패였으면 +/// 으로도) 실린다. 그 둘이 감싸지 않고 던지는 이 예외는 첫째 경우, 곧 장치를 잃은 것뿐이다. /// 에서 파생한다 — 이 아니므로 catch (TimeoutException) 에는 걸리지 않는다. /// public sealed class GevTimeoutException : GevException diff --git a/src/GevSharp/Xml/GevXmlLoader.cs b/src/GevSharp/Xml/GevXmlLoader.cs index 0a89f12..780fffe 100644 --- a/src/GevSharp/Xml/GevXmlLoader.cs +++ b/src/GevSharp/Xml/GevXmlLoader.cs @@ -50,6 +50,8 @@ public static class GevXmlLoader /// 장치가 PENDING_ACK 로 "받아서 실행 중" 이라고 답한 뒤 허락된 연장 안에 끝내지 못한 읽기. /// 그 밖에 시도한 URL 이 모두 같은 구체 형(예: 둘 다 )으로 실패했으면 첫 실패를 그대로 던지고, /// 종류가 다르면(빈 레지스터 포함) 두 사유를 모두 실은 (마지막 실패가 InnerException). + /// 다만 상대가 살아 있던 시한 초과는 시도한 URL 이 모두 그것으로 실패했어도 모은 으로 낸다 — + /// 그래서 이 메서드가 감싸지 않고 던지는 은 언제나 장치 상실(응답 없는 GVCP 요청)이다. /// 취소는 그대로 전파된다. /// /// @@ -140,7 +142,7 @@ internal static async Task LoadAsync(IGevPort port, string? cacheDir, } // 시도한 URL 이 모두 같은 구체 형으로 실패했으면 그 형이 곧 답이다 — 첫 실패를 스택까지 그대로 던진다(다른 사유는 경고 로그에 있다). - // 형이 GevException 그 자체면 두 사유를 합쳐도 형이 같으므로 합친 쪽을 낸다. + // 형이 GevException 그 자체면 두 사유를 합쳐도 형이 같으므로 합친 쪽을 낸다. 시한 초과도 합친 쪽이다(IsSameSpecificKind). if (!hadEmptyRegister && IsSameSpecificKind(errors)) ExceptionDispatchInfo.Capture(errors[0]).Throw(); @@ -164,11 +166,14 @@ internal static bool IsDeviceLoss(Exception ex, GevXmlUrlKind? fetchKind) && 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)) return false; + if (type == typeof(GevException) || type == typeof(GevTimeoutException)) return false; foreach (var e in errors) { if (e.GetType() != type) return false; diff --git a/tests/GevSharp.Tests/Xml/GevXmlLoaderHttpTimeoutTests.cs b/tests/GevSharp.Tests/Xml/GevXmlLoaderHttpTimeoutTests.cs new file mode 100644 index 0000000..d04cbe9 --- /dev/null +++ b/tests/GevSharp.Tests/Xml/GevXmlLoaderHttpTimeoutTests.cs @@ -0,0 +1,82 @@ +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.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 b6460bc..d0a4a51 100644 --- a/tests/GevSharp.Tests/Xml/GevXmlLoaderTests.cs +++ b/tests/GevSharp.Tests/Xml/GevXmlLoaderTests.cs @@ -561,6 +561,25 @@ public async Task APendingAckExpiredUrlRegisterReadFallsBackToTheSecondUrl() 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] From 49096fbeb9fd8cf9753e3705b57b152044ad1bf1 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 15:21:41 +0900 Subject: [PATCH 34/48] =?UTF-8?q?XML=20=EC=BA=90=EC=8B=9C=20=ED=82=A4?= =?UTF-8?q?=EB=A5=BC=20=EC=9D=BD=EB=8B=A4=EA=B0=80=20=EC=9E=A5=EC=B9=98?= =?UTF-8?q?=EB=A5=BC=20=EC=9E=83=EC=9C=BC=EB=A9=B4=20=EC=82=BC=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=EA=B7=B8=20=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=8D=98=EC=A7=84=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XmlCacheDir 를 켜면 XML 을 가져오기 전에 GVBS 의 제조사·모델·버전을 읽어 캐시 키를 만든다. 그 읽기의 실패는 종류를 가리지 않고 경고 한 줄로 삼키고 캐시 없이 이어 갔다. 장치를 잃은 경우에도 그래서, Local: URL 은 XML 영역을 읽느라 GVCP 재시도 예산을 한 번 더 다 쓰고 나서야 같은 상실을 알렸고(URL 레지스터·캐시 키·XML 순으로 무응답 두 번), http·File: URL 은 상실을 아예 알리지 않은 채 문서를 돌려주었다. - 캐시 키 읽기에서 장치 상실(제어 상실, 해제, 응답 없는 시한 초과)은 URL 레지스터 읽기와 같이 그대로 던진다. 장치가 살아서 답한 실패(상태 오류, PENDING_ACK 연장 소진)는 전처럼 경고만 남기고 캐시 없이 이어 간다. - 장치 상실 판정을 URL 종류가 아니라 예외에 붙은 표식으로 가른다. 지금까지는 "http URL 을 가져오는 단계의 시한 초과는 서버 쪽" 으로 갈랐는데, 캐시 키는 http URL 을 적재할 때도 장치에서 읽으므로 그 자리의 GVCP 무응답을 서버 탓으로 읽었다(캐시 키 쪽만 고친 채로 돌리면 http 시험이 상실 대신 모은 GevException 을 받는다). http 내려받기의 시한 초과에 내부 키 HttpTimeout 표식을 달고, IsDeviceLoss 는 표식 없는 시한 초과만 상실로 본다. 표식을 달지 않는 포트 구현의 시한 초과는 전처럼 상실이다. - 시험: 캐시 키에서 무응답이면 XML 영역을 읽지 않고 곧바로 GevTimeoutException(고치기 전에는 무응답 두 번), http URL 이어도 캐시 키의 무응답이 상실로 나가고 서버에 요청하지 않는다(고치기 전에는 문서가 나왔다), 대조군으로 상태 오류·PENDING_ACK 연장 소진은 캐시 없이 적재가 끝난다. 10 초 http 시한 시험에는 실제로 던지는 자리가 표식을 단다는 확인을 더했다. - LoadAsync·LoadFromUrlAsync 문서와 architecture.md 의 XML 적재 규칙에 캐시 키 읽기와 표식 판정을 적었다. --- docs/architecture.md | 12 ++- src/GevSharp/Xml/GevXmlLoader.cs | 52 +++++++--- .../Xml/GevXmlLoaderHttpTimeoutTests.cs | 1 + tests/GevSharp.Tests/Xml/GevXmlLoaderTests.cs | 99 ++++++++++++++++--- 4 files changed, 130 insertions(+), 34 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 6adec71..4c22489 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -511,11 +511,13 @@ 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 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 channel marks that timeout). Otherwise, if every +(`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 diff --git a/src/GevSharp/Xml/GevXmlLoader.cs b/src/GevSharp/Xml/GevXmlLoader.cs index 780fffe..fbac3aa 100644 --- a/src/GevSharp/Xml/GevXmlLoader.cs +++ b/src/GevSharp/Xml/GevXmlLoader.cs @@ -20,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 항목의 선언 크기와 실제 압축 해제량 모두 이 값을 넘으면 메모리에 쌓기 전에 거부한다 — 장치가 준 값 하나로 호스트가 거대 할당을 하지 않게. @@ -44,7 +50,7 @@ public static class GevXmlLoader /// /// 던지는 것 — 호출자가 형으로 "다시 연결" 과 "XML 이 틀렸다" 를 가를 수 있게 원래 형을 지킨다: /// 포트가 장치를 잃었다고 알리면(, , - /// — URL 레지스터 읽기나 Local: 메모리 읽기에서) 다른 URL 로 넘어가지 않고 그 예외를 + /// — URL 레지스터 읽기, 캐시를 켰을 때의 캐시 키 읽기, Local: 메모리 읽기 어디서든) 다른 URL 로 넘어가지 않고 그 예외를 /// 감싸지 않은 채 그대로 던진다(다른 URL 도 같은 포트를 거쳐 재시도 예산만 한 번 더 쓴다). 두 번째 시도에서 잃었어도 같다. /// 시한 초과라도 상대가 살아 있던 것은 여기에 들지 않는다 — 다른 URL 로 넘어간다: http 내려받기의 시한 초과(장치가 아니라 서버 쪽 사정), /// 장치가 PENDING_ACK 로 "받아서 실행 중" 이라고 답한 뒤 허락된 연장 안에 끝내지 못한 읽기. @@ -107,7 +113,7 @@ internal static async Task LoadAsync(IGevPort port, string? cacheDir, { throw; } - catch (Exception ex) when (IsDeviceLoss(ex, null)) + 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; @@ -128,7 +134,7 @@ internal static async Task LoadAsync(IGevPort port, string? cacheDir, { throw; } - catch (Exception ex) when (IsDeviceLoss(ex, url.Kind)) + 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; @@ -153,16 +159,18 @@ internal static async Task LoadAsync(IGevPort port, string? cacheDir, /// /// 포트가 장치를 잃었다는 실패인지 — 제어 상실, 응답 없는 시한 초과, 해제된 장치. 이런 실패 뒤에는 다른 URL 도 같은 포트를 거쳐 - /// 같은 이유로 실패하므로 넘어가지 않고 원래 형 그대로 던진다. fetchKind 는 가져오기 단계의 URL 종류(URL 레지스터 읽기 단계면 null) — - /// http 내려받기의 시한 초과는 서버 쪽 사정이라 장치 상실이 아니다. - /// 장치가 PENDING_ACK 로 답한 뒤 연장 안에 끝내지 못한 시한 초과( 표식)도 상실이 아니다 — - /// 장치는 살아 있고 이 읽기만 못 끝났으니, 다른 URL 은 끝날 수 있다. + /// 같은 이유로 실패하므로 넘어가지 않고 원래 형 그대로 던진다. + /// 시한 초과라도 상대가 살아 있던 것은 상실이 아니다 — http 서버가 답하지 않은 내려받기( 표식), + /// 장치가 PENDING_ACK 로 답한 뒤 연장 안에 끝내지 못한 요청( 표식). + /// 어느 단계(URL 종류)에서 났는지가 아니라 예외에 붙은 표식으로 가른다: http URL 을 적재하는 중에도 캐시 키는 장치에서 읽으므로, + /// URL 종류로 가르면 그 자리의 GVCP 무응답을 서버 탓으로 잘못 읽는다. 표식이 없는 시한 초과는 상실로 본다 + /// (표식을 달지 않는 포트 구현도 같은 판정을 받는다). /// - internal static bool IsDeviceLoss(Exception ex, GevXmlUrlKind? fetchKind) + internal static bool IsDeviceLoss(Exception ex) => ex is GevControlLostException || ex is ObjectDisposedException || (ex is GevTimeoutException - && fetchKind != GevXmlUrlKind.Http + && ex.Data[HttpTimeoutKey] is not true && ex.Data[GvcpChannel.PendingAckExpiredKey] is not true); // 예외가 하나 이상이고 전부 GevException 보다 구체적인 같은 형인지. @@ -183,11 +191,14 @@ private static bool IsSameSpecificKind(List errors) /// /// 해석된 URL 하나로 XML 을 가져온다(First/Second 폴백 없음). cacheDir 가 있으면 장치 식별 문자열로 캐시 파일을 찾고, - /// 적중하면 XML 본문 전송 없이 캐시 텍스트를 돌려준다. 캐시 읽기·쓰기 실패는 경고 로그로만 남고 결과에는 영향이 없다. - /// 가져오기 실패는 사유와 출처를 실은 이되, Local: 메모리 읽기 중 포트가 장치를 잃었다고 알린 - /// ·· 은 감싸지 않고 그대로 던진다. - /// 장치가 PENDING_ACK 로 답한 뒤 연장 안에 끝내지 못한 메모리 읽기는 장치를 잃은 것이 아니라 다른 읽기 실패처럼 감싼다. - /// http 내려받기가 시한을 넘기면 . + /// 적중하면 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); @@ -296,7 +307,7 @@ private static async Task ReadDeviceMemoryAsync(IGevPort port, GevXmlUrl { throw; } - catch (Exception ex) when (IsDeviceLoss(ex, GevXmlUrlKind.Local)) + catch (Exception ex) when (IsDeviceLoss(ex)) { // 장치 상실은 파일 이름을 붙여 감싸지 않는다 — 형이 곧 호출자의 판단 근거다(다시 연결). throw; @@ -370,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) { @@ -475,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.Tests/Xml/GevXmlLoaderHttpTimeoutTests.cs b/tests/GevSharp.Tests/Xml/GevXmlLoaderHttpTimeoutTests.cs index d04cbe9..b0d245b 100644 --- a/tests/GevSharp.Tests/Xml/GevXmlLoaderHttpTimeoutTests.cs +++ b/tests/GevSharp.Tests/Xml/GevXmlLoaderHttpTimeoutTests.cs @@ -76,6 +76,7 @@ public async Task AnHttpTimeoutStaysWrappedSoItIsNotReadAsDeviceLoss() 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 d0a4a51..fb06c54 100644 --- a/tests/GevSharp.Tests/Xml/GevXmlLoaderTests.cs +++ b/tests/GevSharp.Tests/Xml/GevXmlLoaderTests.cs @@ -481,19 +481,20 @@ public async Task SameStatusFailureOnBothUrlRegistersKeepsTheStatusType() public void HttpTimeoutIsNotDeviceLossSoTheOtherUrlIsStillTried() { // http 내려받기의 시한 초과는 서버 쪽 사정이다 — 다른 URL(대개 Local:)로 넘어갈 이유가 남아 있다. - // 끝에서 끝까지 밟으려면 내려받기 시한(10 초)을 실제로 기다려야 해서, 가르는 판정 자체를 본다. - var timeout = new GevTimeoutException("no reply"); - - Assert.False(GevXmlLoader.IsDeviceLoss(timeout, GevXmlUrlKind.Http)); - Assert.True(GevXmlLoader.IsDeviceLoss(timeout, GevXmlUrlKind.Local)); - Assert.True(GevXmlLoader.IsDeviceLoss(timeout, null)); // URL 레지스터 읽기 - // 장치가 PENDING_ACK 로 답한 뒤 연장을 다 쓴 시한 초과 — 장치는 살아 있다. 어느 단계에서 났든 상실이 아니다. - Assert.False(GevXmlLoader.IsDeviceLoss(PendingAckExpired(), GevXmlUrlKind.Local)); - Assert.False(GevXmlLoader.IsDeviceLoss(PendingAckExpired(), null)); - Assert.True(GevXmlLoader.IsDeviceLoss(new GevControlLostException("lost"), null)); - Assert.True(GevXmlLoader.IsDeviceLoss(new ObjectDisposedException("GevDevice"), GevXmlUrlKind.Local)); - Assert.False(GevXmlLoader.IsDeviceLoss(new GevStatusException("READMEM", GvcpConst.StatusInvalidAddress), null)); - Assert.False(GevXmlLoader.IsDeviceLoss(new GevException("bad XML"), GevXmlUrlKind.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] @@ -1040,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] From 93c0d28b1cbdc3a9f649c1178f10a30ede1d91ce Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 15:12:24 +0900 Subject: [PATCH 35/48] =?UTF-8?q?GvcpChannel=20=EC=9D=B4=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=EC=9E=90=EC=9D=98=20IPEndPoint=20=EB=8C=80=EC=8B=A0?= =?UTF-8?q?=20=EC=9E=90=EA=B8=B0=20=EC=82=AC=EB=B3=B8=EC=9D=84=20=EC=A5=90?= =?UTF-8?q?=EC=96=B4,=20=EC=97=B0=20=EB=92=A4=EC=97=90=20=EA=B7=B8=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=EB=A5=BC=20=EB=B0=94=EA=BF=94=EB=8F=84=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EB=8C=80=EC=A1=B0=EA=B0=80=20=EA=B9=A8?= =?UTF-8?q?=EC=A7=80=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GevDevice.OpenAsync(IPEndPoint) 가 공개되면서 호출자가 한 IPEndPoint 객체를 여러 장치에 돌려 쓰는 경로가 흔해졌다. 채널은 송신 주소를 생성 시 직렬화한 사본으로 쓰면서 DeviceEndPoint 에는 호출자의 객체를 그대로 두었기 때문에, 열고 나서 호출자가 Port·Address 를 바꾸면 요청은 원래 장치로 나가는데 응답 대조(HandlePacket)가 바뀐 값을 보고 진짜 장치의 응답을 전부 남의 패킷으로 셌다. 모든 요청이 시한 초과로 끝나고 하트비트 실패로 제어권 상실이 나지만 원인은 어디에도 남지 않았다(오류 메시지·로그에도 바뀐 주소가 찍혔다). 생성자가 null·IPv4 검사 뒤 IPEndPoint 사본을 만들고, DeviceEndPoint·로그 이름·직렬화 송신 주소·수신 스레드 이름을 모두 그 사본에서 끌어낸다. DeviceEndPoint 문서에 사본이라는 것을 적었다. 시험: OpenAsync(ep) 로 연 뒤 ep.Port = 1, ep.Address = 127.0.0.2 로 바꾸고 레지스터 읽기가 성공하는지, 남의 패킷 수가 늘지 않는지, DeviceEndPoint 가 연 장치를 가리키는지 본다. 고치기 전 코드에서는 "READREG to 127.0.0.2:1 timed out after 2 attempt(s) of 3000 ms" 로 실패했다. --- src/GevSharp/Gvcp/GvcpChannel.cs | 10 ++++++-- .../Integration/DeviceLifecycleTests.cs | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/GevSharp/Gvcp/GvcpChannel.cs b/src/GevSharp/Gvcp/GvcpChannel.cs index e721b3d..e82eb36 100644 --- a/src/GevSharp/Gvcp/GvcpChannel.cs +++ b/src/GevSharp/Gvcp/GvcpChannel.cs @@ -88,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); + DeviceEndPoint = device; + _logSrc = $"{LogSrc} {DeviceEndPoint.Address}"; // 호출자가 준 인스턴스를 그대로 쥐지 않고 값만 옮겨 온다 — 채널이 세션에 맞춰 상한을 다시 정할 때(SetMaxPendingAckWaitMs) // 호출자의 객체를, 나아가 같은 객체로 만든 다른 채널까지 조용히 바꿔 놓지 않기 위해서다. // ⚠ GvcpChannelOpt 에 항목을 더하면 여기에도 더한다. @@ -136,6 +141,7 @@ public GvcpChannel(IPEndPoint device, IPAddress? localAddress = null, GvcpChanne internal Action? OnClosed { get; set; } public IPEndPoint LocalEndPoint { get; } + /// 이 채널이 겨냥하는 장치 끝점 — 생성자에 넘긴 객체의 사본이라, 그 객체를 뒤에 바꿔도 이 채널은 처음 연 장치에 묶여 있다. public IPEndPoint DeviceEndPoint { get; } /// 이 채널이 실제로 쓰는 타이밍 값(생성자에 넘긴 객체의 사본). 진단용으로 읽는다. public GvcpChannelOpt Opt => _opt; diff --git a/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs b/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs index 5f7bd65..63c4952 100644 --- a/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs +++ b/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs @@ -537,6 +537,29 @@ public async Task OpenAsync_EndPoint_RejectsPortZero() 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); + } + // ---------------------------------------------------------------- stream vs. device lifetime [Fact] From 41641e512cf4279c537f2a0bce5eb68081d2d6de Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 15:14:22 +0900 Subject: [PATCH 36/48] =?UTF-8?q?=EC=8B=9C=EB=AE=AC=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20Reboot=20=EA=B0=80=20=EC=A0=9C=EC=96=B4=EA=B6=8C=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C(null)=20=EC=95=8C=EB=A6=BC=EC=9D=84=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9=20=EC=9E=A0=EA=B8=88=20=EC=95=88=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=98=AC=EB=A0=A4,=20=EC=83=88=20=EB=B3=B4?= =?UTF-8?q?=EC=9C=A0=EC=9E=90=20=EC=95=8C=EB=A6=BC=EB=B3=B4=EB=8B=A4=20?= =?UTF-8?q?=EB=8A=A6=EA=B2=8C=20=EB=8B=BF=EC=A7=80=20=EC=95=8A=EA=B2=8C=20?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reboot 는 명령 잠금(_commandGate)을 푼 뒤에 ControlOwnerChanged(null) 을 올렸다. 그 틈에 응답기 스레드가 다른 호스트의 CCP 쓰기를 처리하면 새 보유자 알림이 먼저 나가, 관찰자는 [새 보유자, null] 을 받고 누군가 쥔 장치를 "아무도 안 쥐었다" 로 읽었다. 서버 스레드의 획득·해제·만료 알림은 이미 같은 잠금 안에서 올라가므로, 재부팅의 null 도 잠금 안에서 올리면 알림 순서가 상태가 바뀐 순서와 같아진다. 서버 스레드로 넘기지 않은 것은, 재부팅 직후 동기로 null 을 확인하는 기존 시험과 시작하지 않은 시뮬레이터에서도 알림이 나가야 하기 때문이다. 이벤트 문서가 "서버 스레드에서 호출된다" 고만 적고 있어, 재부팅의 null 은 Reboot 를 부른 스레드에서 올라간다는 것, 모든 알림이 명령 처리와 같은 잠금 안이라 순서가 보존된다는 것, 그래서 처리기가 이 장치의 GVCP 응답을 기다리면 응답기도 멈춘다는 것을 적었다. Reboot 문서와 sim-register-map.md 에도 한 구절씩 맞췄다. 시험: 앞선 관찰자가 재부팅의 null 을 받는 자리에서 다른 호스트의 CCP 쓰기를 보내고 ACK 를 잠시 기다린다. 고치기 전에는 그 대기 동안 응답기가 쓰기를 처리해 [새 보유자, null] 로 결정적으로 뒤집혔다(Assert.Equal 컬렉션 불일치). --- docs/sim-register-map.md | 3 +- tests/GevSharp.Sim/SimDevice.cs | 16 +++++++--- tests/GevSharp.Tests/Sim/SimGvcpTests.cs | 39 ++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/docs/sim-register-map.md b/docs/sim-register-map.md index c176ad5..7eb9ebf 100644 --- a/docs/sim-register-map.md +++ b/docs/sim-register-map.md @@ -176,7 +176,8 @@ the receiver reports `Stride` 0). `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), and returns every volatile register to its power-on value: CCP, PrimaryAppPort/Ip, + 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. diff --git a/tests/GevSharp.Sim/SimDevice.cs b/tests/GevSharp.Sim/SimDevice.cs index ecc0d4f..1ed66e6 100644 --- a/tests/GevSharp.Sim/SimDevice.cs +++ b/tests/GevSharp.Sim/SimDevice.cs @@ -145,7 +145,12 @@ public IPEndPoint? ControlOwner public bool IsAcquiring => Registers.ReadU32(SimFeatureAddr.AcquisitionStatus) != 0; - /// 제어권 보유자가 바뀔 때(획득·해제·타임아웃). 서버 스레드에서 호출된다. + /// + /// 제어권 보유자가 바뀔 때(획득·해제·타임아웃·). 획득·해제·타임아웃은 서버 스레드에서, 재부팅이 비운 것(null)은 + /// 를 부른 스레드에서 호출된다. 어느 쪽이든 GVCP 명령 처리와 같은 잠금 안에서 올라가므로 관찰자는 바뀐 순서대로 + /// 받는다 — 재부팅의 null 은 그 뒤에 잡은 새 보유자보다 항상 먼저다. 그 잠금 안이라 처리기가 이 장치의 GVCP 응답을 기다리면 + /// 그동안 응답기도 멈춘다(처리기가 끝나야 다음 명령을 처리한다). + /// public event Action? ControlOwnerChanged; /// 프레임 하나의 전송이 끝났을 때(블록 ID). 송신 스레드에서 호출된다. @@ -223,7 +228,7 @@ public void Stop() /// 남는 것: 식별·영속 IP·사용자 이름 같은 비휘발 레지스터, 관찰용 카운터와 FrameCounter 레지스터(시뮬레이터의 생애를 센다). /// /// 처리 중인 GVCP 명령이 끝난 뒤, 다음 명령 전에 한꺼번에 일어난다. 보유자가 있었으면 (null) 이 - /// 한 번 올라간다. 호스트 쪽에서는 다음 하트비트가 CCP = 0 을 읽어 제어권 상실(장치 재시작 계열 사유)을 알리고, 새 세션이 기다림 없이 + /// 이 메서드를 부른 스레드에서, 다음 명령이 처리되기 전에 한 번 올라간다. 호스트 쪽에서는 다음 하트비트가 CCP = 0 을 읽어 제어권 상실(장치 재시작 계열 사유)을 알리고, 새 세션이 기다림 없이 /// 제어권을 잡는다. 꺼져 있는 동안의 공백(응답하지 않는 시간)은 흉내 내지 않는다. /// /// @@ -233,10 +238,10 @@ public void Stop() /// public void Reboot() { - IPEndPoint? previousOwner; lock (_commandGate) { StopAcquisition(join: true); + IPEndPoint? previousOwner; lock (_gate) { previousOwner = _owner; @@ -248,8 +253,11 @@ public void Reboot() Interlocked.Exchange(ref _softwareTriggerPending, 0); lock (_history) _history.Clear(); ResetFeatures(); + // 명령 잠금 안에서 올린다 — 서버 스레드의 획득·해제·만료 알림도 같은 잠금 안에서 올라가므로, 이 null 이 끝나기 전에는 + // 다음 명령(다른 호스트의 CCP 쓰기)이 처리되지 않고 관찰자는 [null, 새 보유자] 순서로만 받는다. + // 잠금을 풀고 올리면 그 틈에 응답기가 새 보유자를 먼저 알려 [새 보유자, null] 로 뒤집힐 수 있다. + if (previousOwner is not null) ControlOwnerChanged?.Invoke(null); } - if (previousOwner is not null) ControlOwnerChanged?.Invoke(null); } /// 피처 페이지를 생성 시 옵션 값으로 되돌린다(UserSetLoad). FrameCounter 는 유지한다. diff --git a/tests/GevSharp.Tests/Sim/SimGvcpTests.cs b/tests/GevSharp.Tests/Sim/SimGvcpTests.cs index 1663f75..2cfe646 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] From 884644b328aea6849e8ca5f4c8cfb9e495c04b0c Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 15:16:15 +0900 Subject: [PATCH 37/48] =?UTF-8?q?=EC=8B=9C=EB=AE=AC=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EC=9D=98=20=ED=83=80=EC=9E=84=EC=8A=A4=ED=83=AC?= =?UTF-8?q?=ED=94=84=20reset=20=EC=9D=B4=20=EC=B9=B4=EC=9A=B4=ED=84=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=EB=A1=9C=20=EB=90=98=EB=8F=8C?= =?UTF-8?q?=EB=A6=AC=EB=8A=94=EC=A7=80=20=EA=B0=80=EB=A5=B4=EB=8A=94=20?= =?UTF-8?q?=EB=8B=A8=EC=A0=95=EC=9D=84=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TimestampControl(0x0944) 값 1(reset)을 다루는 두 시험은 시뮬레이터를 연 직후에 reset 하고 20 ms 뒤 래치 값을 10 ms..20 s 범위로만 보았다. 시작 뒤 흐른 시간도 그 범위에 들어가므로 reset 갈래가 아무것도 안 해도 둘 다 통과했다. 시뮬레이터는 같은 프로세스라 호스트의 Stopwatch 와 같은 시계로 센다. 그래서 카운터를 250 ms 흘려 둔 뒤 reset 을 보내기 직전부터 latch 가 끝날 때까지를 호스트가 재고, reset 뒤 래치 값이 그 괄호 안에 드는지를 본다. 부하는 괄호만 늘리므로 올바른 시뮬레이터에서는 흔들리지 않고, reset 이 무동작이면 흘려 둔 시간을 싣고 괄호를 넘는다. - SimGvcpTests.TimestampReset_RestartsTheRunningCounter: 원시 GVCP 로 latch → reset → latch. 판별력의 전제(reset 전 값 ≥ 200 ms)도 단정한다. - SimNodeMapTests.GevTimestampNodes_ResetAndLatchTheDeviceCounter: 같은 괄호 단정을 GevTimestampControlReset 노드 경로에 더해, XML 의 reset 노드가 엉뚱한 CommandValue 를 실어도 드러나게 한다. reset 갈래를 무동작으로 바꾼 시뮬레이터에서 새 단정 둘이 실패하는 것(263 ms 대 괄호 0.19 ms, 300 ms 대 25.7 ms)과 기존 두 시험은 그대로 통과하는 것을 확인했다. --- .../GenApi/Runtime/SimNodeMapTests.cs | 8 +++++ tests/GevSharp.Tests/Sim/SimGvcpTests.cs | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/tests/GevSharp.Tests/GenApi/Runtime/SimNodeMapTests.cs b/tests/GevSharp.Tests/GenApi/Runtime/SimNodeMapTests.cs index 0d3e78d..c68132d 100644 --- a/tests/GevSharp.Tests/GenApi/Runtime/SimNodeMapTests.cs +++ b/tests/GevSharp.Tests/GenApi/Runtime/SimNodeMapTests.cs @@ -352,14 +352,22 @@ public async Task GevTimestampNodes_ResetAndLatchTheDeviceCounter() 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()); diff --git a/tests/GevSharp.Tests/Sim/SimGvcpTests.cs b/tests/GevSharp.Tests/Sim/SimGvcpTests.cs index 2cfe646..fb77ec8 100644 --- a/tests/GevSharp.Tests/Sim/SimGvcpTests.cs +++ b/tests/GevSharp.Tests/Sim/SimGvcpTests.cs @@ -636,6 +636,37 @@ 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() { From a29bdd184006fd02dbcb2ca74a155ee38be664ee Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 15:17:20 +0900 Subject: [PATCH 38/48] =?UTF-8?q?CLI=20=EC=9D=98=20InternalsVisibleTo=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=EA=B3=BC=20DeviceTarget.ProbeAsync=20?= =?UTF-8?q?=EC=9D=98=20null=20=EC=84=A4=EB=AA=85=EC=9D=84=20=ED=98=84?= =?UTF-8?q?=EC=9E=AC=20=EA=B3=84=EC=95=BD=EC=97=90=20=EB=A7=9E=EC=B6=98?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GevSharp.csproj 의 주석은 CLI 가 표준 포트가 아닌 장치를 "열려고" IPEndPoint 오버로드가 필요하다고 적고 있었지만, GevDevice.OpenAsync(IPEndPoint) 는 이제 공개다. CLI 에서 이 권한이 필요한 것은 내부 GevDiscovery.ProbeAsync(IPEndPoint) 하나다 — 권한을 빼고 CLI 를 지으면 DeviceTarget.cs 의 그 호출만 깨지는 것으로 확인했다. 권한을 치우려는 사람이 엉뚱한 호출을 찾지 않도록 그렇게 적었다. DeviceTarget.ProbeAsync 의 요약은 "응답이 없으면 null" 이었지만, 라이브러리 계약상 null 은 시간 안에 응답이 없을 때만이 아니라 장치가 오류 status 로 답했을 때, 응답이 탐색 블록보다 짧을 때도 나온다(뒤의 둘은 Warn 로그). DiscoverCmd 와 architecture.md 가 이미 그렇게 적고 있어 이 요약만 어긋나 있었다. 클래스 요약의 "IPEndPoint 오버로드로 연다" 도 공개 OpenAsync(IPEndPoint) 라고 밝히고, 프로브만 내부 멤버라는 것을 덧붙였다. --- samples/GevSharp.Cli/Commands/DeviceTarget.cs | 8 ++++++-- src/GevSharp/GevSharp.csproj | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) 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/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 @@ - + From b8e1d97377fc01c80f803a043d48d763002cdca1 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 15:19:50 +0900 Subject: [PATCH 39/48] =?UTF-8?q?GevDevice.OpenAsync=20=EC=84=B8=20?= =?UTF-8?q?=EC=98=A4=EB=B2=84=EB=A1=9C=EB=93=9C=EC=97=90=20=EB=8D=98?= =?UTF-8?q?=EC=A7=80=EB=8A=94=20=EC=98=88=EC=99=B8=20=EC=A0=84=EB=B6=80?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=81=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공개된 OpenAsync(IPEndPoint) 는 포트 0 의 ArgumentOutOfRangeException 하나만 적고 있었고, 나머지 두 오버로드는 예외를 하나도 적지 않았다. 세 오버로드는 같은 열기 경로(OpenCoreAsync)를 타므로 같은 집합을 각자 적었다: - ArgumentNullException: 인자가 null (호출 자리에서 던진다) - ArgumentOutOfRangeException: 포트 0(IPEndPoint 오버로드), GevDeviceOpt 값 범위(Validate) - GevException: IPv4 가 아닌 주소, 장치로 나가는 로컬 주소를 정할 수 없음, 보내기 소켓 오류. 열기 순서에서 장치와 주고받다 난 실패는 하위 형식(GevTimeoutException·GevStatusException·GevControlLostException)으로 온다 — 자세한 갈래는 IPEndPoint 오버로드에 적고 나머지 둘은 그쪽을 가리킨다. 실패한 열기가 세션을 닫고 내보낸 CCP 를 짧은 예산 안에서 놓아 주려 한다는 것도 적었다. - SocketException: GVCP 소켓을 로컬 주소에 묶지 못함(감싸지 않고 그대로 나온다) - OperationCanceledException: 취소 IPEndPoint 오버로드 요약에는 세션이 넘겨받은 끝점의 사본을 쥔다는 것도 적었다. 시험(OpenAsync_EndPoint_ThrowsWhatItsDocumentationLists)으로 값싼 갈래를 못 박는다: null 은 동기로, IPv6 끝점은 로컬 주소를 스스로 정하든 옵션으로 주든 GevException, 이 호스트의 주소가 아닌 LocalAddress(192.0.2.1, 묶기에서 끝나 아무것도 보내지 않는다)는 SocketException, 미리 취소된 토큰은 OperationCanceledException 이고 시뮬레이터에 쓰기가 하나도 닿지 않는다. IPv6 LocalAddress 도 Windows 에서 SocketException(주소 계열 불일치)으로 나오는 것을 확인해 묶기 실패 문구에 포함했다. 시한 초과·제어권 거절·옵션 범위는 기존 시험이 이미 다룬다. --- src/GevSharp/GevDevice.cs | 31 ++++++++++++++++++- .../Integration/DeviceLifecycleTests.cs | 25 +++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/GevSharp/GevDevice.cs b/src/GevSharp/GevDevice.cs index 838cacf..aee5932 100644 --- a/src/GevSharp/GevDevice.cs +++ b/src/GevSharp/GevDevice.cs @@ -98,6 +98,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)); @@ -106,6 +114,14 @@ 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)); @@ -116,8 +132,21 @@ public static Task OpenAsync(IPAddress address, GevDeviceOpt? opt = n /// 주소와 GVCP 포트로 연다 — 표준 포트(3956)가 아닌 곳에서 답하는 장치용: 루프백의 시뮬레이터, 포트를 옮겨 둔 NAT·포워딩 뒤의 장치 등. /// 로컬 주소는 옵션 → 같은 서브넷 인터페이스 → OS 라우팅 순으로 정한다. IPv4 만 받는다. /// 이 포트는 제어 채널(레지스터 접근·하트비트·리센드 요청)에만 쓰인다 — 스트림은 장치가 자기 설정대로 보내는 곳에서 받는다. + /// 세션은 의 사본을 쥔다 — 연 뒤에 그 객체를 다른 장치에 다시 써도 이 세션은 처음 연 장치에 묶여 있다. /// - /// 포트가 0 이다. + /// 가 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)); diff --git a/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs b/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs index 63c4952..4c820a2 100644 --- a/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs +++ b/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs @@ -560,6 +560,31 @@ public async Task OpenAsync_EndPoint_KeepsItsOwnCopy_SoReusingTheCallersEndPoint 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] From 9459a51221906c9437b82f021d21a6bf2abe899c Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 15:25:07 +0900 Subject: [PATCH 40/48] =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A6=BC=20=EC=A0=95?= =?UTF-8?q?=EC=A7=80=EC=9D=98=20SCP=C2=B7SCDA=20=EB=81=84=EA=B8=B0?= =?UTF-8?q?=EC=97=90=20=ED=98=B8=EC=B6=9C=EC=9E=90=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=C2=B7=EC=9E=AC=EC=8B=9C=EB=8F=84=EC=99=80=20=EB=AC=B4=EA=B4=80?= =?UTF-8?q?=ED=95=9C=20=EA=B3=A0=EC=A0=95=20=EC=98=88=EC=82=B0=EC=9D=84=20?= =?UTF-8?q?=EC=A4=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정지는 호출자의 토큰과 무관하게 장치 전송 끄기(SCP = 0, SCDA = 0)를 시도하는데, 두 쓰기가 CancellationToken.None 으로 채널의 재시도 예산 전부를 쓰고 있었다. 장치가 GVCP 에 답하지 않게 되면 정지가 쓰기 둘 × (1 + GvcpRetries) × GvcpTimeoutMs 에 재시도 중인 하트비트 뒤의 줄서기까지 붙들리고, 호출자는 짧은 토큰으로도 그것을 끊을 수 없었다. GvcpRetries = int.MaxValue 면 정지와 DisposeAsync 가 돌아오지 않는다. 응답 창 500 ms·재시도 20 회로 시뮬레이터를 멈춘 뒤 정지가 30 초 안에 돌아오지 않았다. 두 쓰기는 이제 장치 닫기의 CCP 해제와 같은 모양의 고정 예산 하나(응답 창 두 개, 많아야 CcpReleaseMaxMs = 2 초)를 자기 CTS 로 함께 쓴다. 공식은 GevDevice.ShutdownWriteBudgetMs 한 곳에 두고 닫기와 OpenStreamAsync 가 같이 부른다. 스트림은 장치의 응답 창을 모르므로 내부 생성자 인자로 받고, 포트 위에 바로 만든 스트림은 상한 2 초를 받는다. 예산이 SCP 에서 다 쓰이면 SCDA 는 보내지 않고 그렇다고 경고를 남긴다. 로컬 정리(소켓 닫기·합류·큐 비우기)는 예전처럼 무조건 끝까지 간다. 실패한 시작의 SCP 되돌리기도 같은 예산으로 묶었다 — 겹친 정지가 그 되돌리기를 자물쇠 앞에서 기다리기 때문이다. 같은 실험이 이제 세 경우(300 ms 토큰, 이미 취소된 토큰, 끝없는 재시도) 모두 예산인 약 1000 ms 에 돌아온다. DeviceLifecycleTests 에 그 세 경우를 못 박고, 장치가 정말 말이 없어 예산을 다 썼는지 하한으로 함께 확인한다. StopAsync 설명과 architecture.md 의 정지 줄을 이 상한에 맞게 고쳤다. --- docs/architecture.md | 6 +- src/GevSharp/GevDevice.Stream.cs | 3 +- src/GevSharp/GevDevice.cs | 9 ++- src/GevSharp/Gvsp/GevStream.cs | 63 ++++++++++++++++--- .../Integration/DeviceLifecycleTests.cs | 57 +++++++++++++++++ 5 files changed, 126 insertions(+), 12 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 4c22489..500729e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -337,7 +337,11 @@ public sealed class GevStream : IAsyncDisposable // leaves those pool buffers held forever — and complete pending receives // 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 + // 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); diff --git a/src/GevSharp/GevDevice.Stream.cs b/src/GevSharp/GevDevice.Stream.cs index 6d441e6..2f34ba6 100644 --- a/src/GevSharp/GevDevice.Stream.cs +++ b/src/GevSharp/GevDevice.Stream.cs @@ -19,7 +19,8 @@ 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(_opt.GvcpTimeoutMs)); return Task.FromResult(stream); } } diff --git a/src/GevSharp/GevDevice.cs b/src/GevSharp/GevDevice.cs index aee5932..5404ab5 100644 --- a/src/GevSharp/GevDevice.cs +++ b/src/GevSharp/GevDevice.cs @@ -32,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; @@ -455,7 +462,7 @@ public async ValueTask DisposeAsync() { // 닫기는 오래 붙들지 않는다 — 채널의 재시도 예산 전부가 아니라 짧은 고정 예산만 준다. // 놓지 못해도 장치는 자기 하트비트 타임아웃으로 알아서 푼다. - var releaseBudgetMs = (int)Math.Min((long)_opt.GvcpTimeoutMs * 2, CcpReleaseMaxMs); + var releaseBudgetMs = ShutdownWriteBudgetMs(_opt.GvcpTimeoutMs); using var releaseCts = new CancellationTokenSource(releaseBudgetMs); try { diff --git a/src/GevSharp/Gvsp/GevStream.cs b/src/GevSharp/Gvsp/GevStream.cs index 3618b70..46ba3e5 100644 --- a/src/GevSharp/Gvsp/GevStream.cs +++ b/src/GevSharp/Gvsp/GevStream.cs @@ -46,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); @@ -64,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)); @@ -76,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(); @@ -211,8 +220,10 @@ public async Task StartAsync(CancellationToken ct = default) 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; @@ -236,8 +247,12 @@ public async Task StartAsync(CancellationToken ct = default) /// 셧다운을 취소 처리로 감싼 호출자는 그 예외 때문에 뒤따르는 정리(장치 닫기 등)를 건너뛰게 되는데, 정작 정지는 끝나 있다. /// /// - /// 기다리는 자리마다 상한이 따로 있다: 겹친 시작·정지가 끝나기를(그쪽도 아래 상한에 묶인다), 레지스터 쓰기는 제어 채널의 - /// 시한·재시도를, 수신 스레드 합류는 2 초를 넘지 않는다. + /// 대신 기다리는 자리마다 상한이 따로 있다. 장치 전송 끄기는 두 쓰기를 합쳐 고정 예산 하나 — 응답 창() + /// 두 개, 많아야 2 초 — 안에서 끝난다. 이 예산은 호출자의 토큰에도 에도 기대지 않으므로, + /// 장치가 답하지 않게 된 뒤에도(재시도가 끝없는 설정에서도) 정지는 그만큼만 쓰고 돌아온다. 예산이 다하면 경고를 남기고 + /// 로컬 정리로 넘어간다 — 그때 장치는 옛 SCP·SCDA 를 그대로 들고 있다. 수신 스레드 합류는 2 초를 넘지 않는다. + /// 겹친 시작이 자물쇠를 쥐고 있으면 그 시작이 끝나기를 먼저 기다리며, 그 시간은 시작에 준 토큰과 제어 채널의 시한·재시도가 정한다 + /// (실패한 시작의 SCP 되돌리기는 위와 같은 고정 예산이다). /// /// /// 어떤 단계도 끊지 않는다(위 설명). 취소돼 있어도 정지를 끝까지 하고 정상으로 돌아온다. @@ -266,10 +281,14 @@ public async Task StopAsync(CancellationToken ct = default) // 장치 전송 끄기는 호출자의 토큰과 무관하게 시도한다(실패한 시작의 되돌리기와 같다). 토큰을 넘기면 SCP 쓰기 도중의 // 취소가 "SCP 쓰기 실패" 로 기록되고, 이미 취소된 토큰을 받은 SCDA 쓰기는 보내지도 못한 채 끝난다. - try { await WriteRegAsync(GvbsAddr.ScpOffset, 0, CancellationToken.None).ConfigureAwait(false); } - catch (Exception ex) { GevLog.Warn(_logSrc, "Failed to write SCP = 0 while stopping the stream.", ex); } - try { await WriteRegAsync(GvbsAddr.ScdaOffset, 0, CancellationToken.None).ConfigureAwait(false); } - catch (Exception ex) { GevLog.Warn(_logSrc, "Failed to write SCDA = 0 while stopping the stream.", ex); } + // 그렇다고 채널의 재시도 예산 전부를 쓰지도 않는다 — 두 쓰기가 합쳐서 고정 예산 하나를 받는다. 채널 예산에 기대면 말없는 + // 장치 앞에서 정지가 쓰기 둘 × (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; @@ -607,6 +626,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/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs b/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs index 4c820a2..f6fba5a 100644 --- a/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs +++ b/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs @@ -609,4 +609,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(); + } + } } From 3badaa6a6d4078ea133ff23ee450ef3c66c49aec Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 15:26:38 +0900 Subject: [PATCH 41/48] =?UTF-8?q?=EB=A6=AC=EC=84=BC=EB=93=9C=EA=B0=80=20?= =?UTF-8?q?=EA=BA=BC=EC=A0=B8=20=EC=9E=88=EC=9C=BC=EB=A9=B4=20=ED=8A=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=BC=EB=9F=AC=20=EC=97=86=EB=8A=94=20=EB=B2=84?= =?UTF-8?q?=EB=A6=B0=20=ED=94=84=EB=A0=88=EC=9E=84=EB=8F=84=20=EC=9E=AC?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EA=B0=84=EA=B2=A9=EC=97=90=20=EB=8B=AB?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리센드를 끄면(ResendEnabled = false 또는 PacketRequestRatio = 0) 시작 로그와 옵션 설명이 FrameRetentionMs 는 쓰이지 않는다고 알리는데, CheckCompletion 의 버린 프레임 갈래(다루지 않는 형식·쓸 수 없는 기하·NoBuffer·패킷 간격 변경 오류)는 리센드 여부와 무관하게 보존 시간을 기다렸다. 그래서 트레일러가 오지 않은 버린 프레임의 FrameDropped 와 FramesDropped* 계수기가 보존 시간만큼 늦고, 그동안 조립 슬롯 넷 중 하나가 묶였다. 리센드를 끄고 보존 시간 3000 ms 에서 payload_type 4 의 12바이트 리더 한 장만 보내면 3003 ms 뒤에야 FrameDropped(Unsupported) 가 왔다. 리센드가 꺼져 있으면 이 갈래도 불완전 프레임처럼 마지막 패킷 뒤 PacketTimeoutMs 에 닫는다. 리센드가 켜진 쪽은 그대로 보존 시간을 쓰며, 그 사실을 FrameRetentionMs 설명·architecture.md·CheckCompletion 요약에 적었다. 새 시험은 같은 프레임을 리센드 꺼짐(재요청 간격 300 ms 근처에서 닫힘)과 켜짐(보존 시간까지 기다림, 대조군)으로 한 번에 잰다. --- docs/architecture.md | 6 ++- src/GevSharp/Gvsp/GevStream.Receiver.cs | 9 ++++- src/GevSharp/Gvsp/GevStreamOpt.cs | 3 ++ tests/GevSharp.Tests/Gvsp/GevStreamTests.cs | 44 +++++++++++++++++++++ 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 500729e..c3c58aa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -300,8 +300,10 @@ public sealed class GevStreamOpt 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; 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; - // with resend off an incomplete frame goes after PacketTimeoutMs, or at once when a newer block starts + 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; diff --git a/src/GevSharp/Gvsp/GevStream.Receiver.cs b/src/GevSharp/Gvsp/GevStream.Receiver.cs index 29aa4ae..0794d55 100644 --- a/src/GevSharp/Gvsp/GevStream.Receiver.cs +++ b/src/GevSharp/Gvsp/GevStream.Receiver.cs @@ -1329,7 +1329,8 @@ private bool SendResend(FrameSlot slot, uint first, uint last, long now, int max /// 오래된 순서로 슬롯을 본다. 완성됐거나 포기해야 할 프레임은 닫고, 아직 기다려야 하는 프레임을 만나면 그 뒤의 프레임은 닫지 않는다(순서 보존). /// 버퍼를 쥐지 않은(건너뛰기) 슬롯은 순서를 막지 않는다. /// 포기 시점: 리센드를 더 요청하지 않는 프레임(예산 소진·장치 거절·리센드 꺼짐)은 마지막 패킷 뒤 재요청 간격 하나만 더 기다리고, - /// 그 밖의 프레임은 보존 시간까지 기다린다. 기다리는 프레임은 마감이 되거나 꼬리가 확정될 때 구멍을 다시 본다. + /// 그 밖의 프레임은 보존 시간까지 기다린다. 버리기로 한 프레임은 트레일러를 받으면 곧바로, 못 받으면 리센드가 켜져 있을 때 보존 시간, + /// 꺼져 있을 때 재요청 간격 뒤에 닫는다. 기다리는 프레임은 마감이 되거나 꼬리가 확정될 때 구멍을 다시 본다. /// private void CheckCompletion(long now, FrameSlot? current) { @@ -1343,7 +1344,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; diff --git a/src/GevSharp/Gvsp/GevStreamOpt.cs b/src/GevSharp/Gvsp/GevStreamOpt.cs index 33ac8b3..5cbd837 100644 --- a/src/GevSharp/Gvsp/GevStreamOpt.cs +++ b/src/GevSharp/Gvsp/GevStreamOpt.cs @@ -40,8 +40,11 @@ public sealed class GevStreamOpt /// /// 마지막 패킷 도착 후 이 시간이 지나도록 완성되지 않은 프레임은 포기한다 — 리센드를 아직 묻고 있는 프레임에만 쓰인다. + /// 하나 더: 리센드가 켜져 있으면 다른 이유(버퍼 없음·다루지 않는 형식·오류)로 버리기로 한 프레임도 트레일러가 오지 않는 한 + /// 이 시간까지 조립 슬롯을 쥐고, 그 프레임의 도 그때 올라간다. /// 리센드가 꺼져 있으면( = false 또는 = 0) 이 값은 쓰이지 않는다: /// 불완전 프레임은 마지막 패킷 뒤 에 포기되고, 더 새로운 블록이 시작되면 곧바로 닫힌다. + /// 버리기로 한 프레임도 트레일러가 없으면 마지막 패킷 뒤 에 닫힌다. /// 예산을 다 썼거나 장치가 못 준다고 답한 프레임도 에 닫힌다. /// 리더만 받은 가장 새 프레임은 예외로 이 시간이 지나도 기다린다(노출이 긴 촬영에서 리더가 먼저 오는 장치가 있다). /// diff --git a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs index be7e4c3..0cac0af 100644 --- a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs +++ b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs @@ -860,6 +860,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() { From f314a5c8295ff0dcd9c27901a2a8e292dedb5002 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 15:27:28 +0900 Subject: [PATCH 42/48] =?UTF-8?q?CompleteFramesAreDeliveredInOrder=20?= =?UTF-8?q?=EA=B0=80=20=EC=88=98=EC=8B=A0=EA=B8=B0=EA=B0=80=20=EB=B3=B4?= =?UTF-8?q?=EB=82=B8=20=ED=8C=A8=ED=82=B7=EC=9D=84=20=EB=8B=A4=20=EC=84=BC?= =?UTF-8?q?=20=EB=92=A4=EC=97=90=20=EA=B3=84=EC=88=98=EA=B8=B0=EB=A5=BC=20?= =?UTF-8?q?=EB=B3=B4=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프레임은 마지막 페이로드에서 닫혀 큐에 들므로, 다섯째 프레임을 받은 순간 그 블록의 트레일러는 아직 소켓에 있을 수 있다. 시험은 그 직후 통계를 찍어 PacketsReceived 를 보낸 패킷 수와 견주었기 때문에, 수신 스레드가 큐에 넣은 뒤 잠깐 밀리기만 해도 25 대 24 로 깨졌다. 수신 스레드가 조립 중인 프레임이 없을 때 패킷 처리 전에 5 ms 쉬게 하는 주입으로 두 경우 모두 같은 줄에서 재현했다. 이제 PacketsReceived 가 보낸 수에 닿을 때까지 기다린 뒤 찍고, 같다는 단정은 그대로 둔다. 또 하나의 오래된 경로도 막았다. 기본 재요청 간격 20 ms 에서는 송신이 프레임 도중 그만큼 멈추면 침묵 규칙이 아직 안 온 꼬리를 물어 ResendRequests 가 0 이 아니게 된다(프레임마다 30 ms 멈추는 주입으로 재현). 이 시험은 침묵 규칙도 보존 시간도 보지 않으므로 재요청 간격 2000 ms, 보존 시간 5000 ms 로 넉넉히 두었다. 프레임은 마지막 페이로드에서 닫히므로 시험은 느려지지 않는다. 두 주입 모두 고친 시험에서는 통과했다. --- tests/GevSharp.Tests/Gvsp/GevStreamTests.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs index 0cac0af..e122ad6 100644 --- a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs +++ b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs @@ -128,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(); @@ -157,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); From f388840952ee4ae8241fed6ebd43c0e60fc3dcd4 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 15:28:00 +0900 Subject: [PATCH 43/48] =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A6=BC=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=20=EC=85=8B=EC=9D=B4=20=EC=88=98=EC=8B=A0=EA=B8=B0=20?= =?UTF-8?q?=EC=AA=BD=20=EC=82=AC=EA=B1=B4=EC=9D=84=20=EA=B8=B0=EB=8B=A4?= =?UTF-8?q?=EB=A6=B0=20=EB=92=A4=20=EA=B3=84=EC=88=98=EA=B8=B0=EB=A5=BC=20?= =?UTF-8?q?=EB=B3=B4=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 셋 다 수신기가 아직 따라오지 않았을 수 있는 순간에 계수기를 찍어, 러너가 밀리면 깨지던 오래된 경합이다. 단정하던 것은 그대로 두고, 기다려야 할 사건을 기다리게 했다. MalformedTrailerDoesNotPinTheFrameOpen: id 0 짜리 깨진 트레일러가 리더 없는 둘째 프레임이 열려 있을 때 닿아야 뜻이 있는데, 송신이 유예(2 ms)보다 늦게 트레일러에 닿으면 리센드로 돌아온 리더가 먼저 프레임을 닫고 깨진 트레일러는 닫힌 블록의 늦은 트레일러로 세지지 않고 지나가 PacketsIgnored >= 1 이 깨졌다(트레일러 앞 15 ms 멈춤 주입으로 재현). 이제 리센드 답을 붙들어 두었다가 수신기가 깨진 트레일러를 거른 것(PacketsIgnored)과 둘째 프레임이 아직 열려 있음을 본 뒤에 답하고, 걸러진 패킷이 정확히 하나인지 단정한다. 리더 없는 프레임이 그사이 보존 시간으로 닫히지 않게 보존 시간을 5000 ms 로 둔다. HostileDeviceTests.ErrorPacketWithAnImpossiblePacketIdStopsTheFrameInsteadOfAllocating: 오류 패킷을 보낸 직후 요청 수를 기준으로 찍어, 수신 스레드가 그 패킷을 꺼내기 전에 20 ms 재요청이 한 번 더 나가면 1 대 2 로 깨졌다(수신 스레드의 틱을 25 ms 늦추는 주입으로 재현). 이제 ErrorPackets 가 오른 뒤 기준을 찍는다. 그 계수기는 오류 패킷 처리의 첫 줄에서 오르고 리센드를 끄는 처리가 같은 스레드에서 요청 없이 뒤따르므로 그 뒤의 기준은 흔들리지 않는다. SlowSenderDoesNotTriggerSpuriousResends: 이 시험이 보는 것은 유예(2 ms)인데, 재요청 간격 1000 ms 도 과부하 러너의 송신 멈춤보다 짧을 수 있어 침묵 규칙이 꼬리를 물면 요청 0 이 깨진다(프레임 도중 1.1 s 멈춤 주입으로 요청 1 건 재현). 재요청 간격 10000 ms, 보존 시간 20000 ms 로 올리고, 트레일러까지 수신기가 다 센 뒤에 요청 수를 본다. 프레임은 마지막 페이로드에서 닫히므로 시험은 느려지지 않는다. 세 주입 모두 고친 시험에서는 통과했고, 주입은 남기지 않았다. --- tests/GevSharp.Tests/Gvsp/GevStreamTests.cs | 26 +++++++++++++++---- .../GevSharp.Tests/Gvsp/HostileDeviceTests.cs | 4 +++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs index e122ad6..3246980 100644 --- a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs +++ b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs @@ -653,10 +653,12 @@ public async Task SlowSenderDoesNotTriggerSpuriousResends() // 침묵 규칙(재요청 간격만큼 조용하면 꼬리를 구멍으로 친다)이 스케줄링 지연에 걸리지 않게 간격을 넉넉히 둔다 — 여기서 보는 것은 유예뿐이다. var opt = StreamRig.DefaultOpt(); // 프레임 전체가 25 ms 안에 나가므로 문턱을 크게 잡아도 "아직 안 온 꼬리는 구멍이 아니다" 라는 성질은 그대로 걸린다. - // 문턱이 러너의 선점보다 짧으면 이 테스트는 유예가 아니라 러너의 스케줄링을 재게 된다. - opt.PacketTimeoutMs = 1000; + // 문턱이 러너의 선점보다 짧으면 이 테스트는 유예가 아니라 러너의 스케줄링을 재게 된다 — 1 s 에서도 스위트 셋을 나란히 돌린 + // 러너에서 "요청 0" 단정이 한 번 깨졌고, 송신을 프레임 도중 1.1 s 멈추는 주입이 같은 실패(요청 1 건)를 낸다. + // 프레임은 마지막 페이로드에서 닫히므로 문턱을 더 올려도 이 시험은 느려지지 않는다. + opt.PacketTimeoutMs = 10_000; // 보존 시간도 마찬가지 — 러너가 밀려 프레임이 포기되면 "군더더기 요청이 없다" 대신 타임아웃이 난다. - opt.FrameRetentionMs = 3000; + opt.FrameRetentionMs = 20_000; await using var rig = new StreamRig(opt); await rig.StartAsync(); @@ -668,6 +670,8 @@ public async Task SlowSenderDoesNotTriggerSpuriousResends() using var received = await rig.ReceiveAsync(); Assert.True(received.IsComplete); Assert.True(received.Data.Span.SequenceEqual(frame.Data)); + // 요청 수는 수신기가 보낸 패킷(트레일러까지)을 다 센 뒤에 본다 — 프레임을 받은 순간에는 트레일러가 아직 소켓에 있을 수 있다. + await rig.WaitUntilAsync(() => rig.Stream.Stats.PacketsReceived >= rig.Sender.PacketsSent); Assert.Equal(0, rig.Resend.RequestCount); Assert.Equal(0, rig.Stream.Stats.ResendRequests); } @@ -1314,7 +1318,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 짜리 깨진 트레일러를 붙인다. @@ -1325,17 +1333,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); } 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; // 더는 묻지 않는다 — 재요청 간격을 여러 번 지나도 요청 수가 늘지 않아야 한다. From 006c146fca2b49add31435c0b3a11c91c534cee5 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 15:48:10 +0900 Subject: [PATCH 44/48] =?UTF-8?q?SlowSenderDoesNotTriggerSpuriousResends?= =?UTF-8?q?=20=EC=9D=98=20=EC=8B=A4=ED=8C=A8=EA=B0=80=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=20=EC=AA=BD=20=EC=9C=A0=EC=8B=A4=EA=B3=BC=20=EA=B5=B0=EB=8D=94?= =?UTF-8?q?=EB=8D=94=EA=B8=B0=20=EC=9A=94=EC=B2=AD=EC=9D=84=20=EA=B0=80?= =?UTF-8?q?=EB=A5=B4=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞 커밋은 이 시험의 실패를 송신 멈춤이 침묵 규칙을 부른 것으로 적고, 트레일러까지 다 셀 때까지 10 초를 기다리게 했다. 스트레스 실행(Gvsp 시험 네 벌을 나란히 12 회)에서 실제로 잡힌 실패는 그것이 아니었다: 보낸 8 개(원본 7 + 리센드 1) 중 7 개만 세었고, 요청은 패킷 3 하나 — 최고 id 아래의 진짜 구멍이었다. 데이터그램 하나가 스트림 소켓까지 와서 세지기 전에 사라졌고, 요청은 그 유실을 메운 것이다. 이때 보낸 수는 리센드 사본까지 늘어나므로 앞 커밋의 기다림은 영영 채워지지 않아 아무 설명 없는 시한 초과로 끝났다. 같은 기계의 루프백에서 따로 잰 결과, 수신 타임아웃 2 ms 인 블로킹 수신이 IOPending 으로 끝난 횟수와 사라진 데이터그램 수가 세 번 모두 같았고(1-1, 2-2), 타임아웃 1000 ms 로 같은 부하에서 3600 개를 보냈을 때는 하나도 잃지 않았다. 수신기는 조립 중인 프레임이 있으면 짧은 수신 타임아웃을 쓰고 IOPending 을 잃은 것 없는 타임아웃으로 다루므로, 패킷 사이마다 타임아웃이 도는 이 시험이 그 유실이 드러나는 자리다. 수신기의 동작은 이 커밋에서 바꾸지 않는다. 그래서 요청 0 단정은 그대로 두고, 기다림을 2 초로 줄인 뒤 다 세지 못하면 센 수·보낸 수·리센드 사본 수·요청 범위와 함께 수신 쪽 유실이라고 밝히며 실패하게 했다. 주석도 관찰한 대로 고쳤다 — 1.1 s 송신 멈춤은 주입으로 재현한 별도 경로이고 넓힌 문턱이 그것을 막는다. --- tests/GevSharp.Tests/Gvsp/GevStreamTests.cs | 23 ++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs index 3246980..ebbc969 100644 --- a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs +++ b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs @@ -653,9 +653,10 @@ public async Task SlowSenderDoesNotTriggerSpuriousResends() // 침묵 규칙(재요청 간격만큼 조용하면 꼬리를 구멍으로 친다)이 스케줄링 지연에 걸리지 않게 간격을 넉넉히 둔다 — 여기서 보는 것은 유예뿐이다. var opt = StreamRig.DefaultOpt(); // 프레임 전체가 25 ms 안에 나가므로 문턱을 크게 잡아도 "아직 안 온 꼬리는 구멍이 아니다" 라는 성질은 그대로 걸린다. - // 문턱이 러너의 선점보다 짧으면 이 테스트는 유예가 아니라 러너의 스케줄링을 재게 된다 — 1 s 에서도 스위트 셋을 나란히 돌린 - // 러너에서 "요청 0" 단정이 한 번 깨졌고, 송신을 프레임 도중 1.1 s 멈추는 주입이 같은 실패(요청 1 건)를 낸다. + // 문턱이 러너의 선점보다 짧으면 이 테스트는 유예가 아니라 러너의 스케줄링을 재게 된다 — 송신을 프레임 도중 1.1 s 멈추면 + // 1 s 문턱의 침묵 규칙이 꼬리를 물어 요청이 1 건 나간다(주입으로 재현). 과부하 러너의 멈춤이 그만큼 길어질 수 있어 문턱을 더 올린다. // 프레임은 마지막 페이로드에서 닫히므로 문턱을 더 올려도 이 시험은 느려지지 않는다. + // (스트레스 실행에서 실제로 잡힌 이 시험의 실패는 이 경로가 아니라 아래에 적은 데이터그램 유실이었다.) opt.PacketTimeoutMs = 10_000; // 보존 시간도 마찬가지 — 러너가 밀려 프레임이 포기되면 "군더더기 요청이 없다" 대신 타임아웃이 난다. opt.FrameRetentionMs = 20_000; @@ -671,7 +672,23 @@ public async Task SlowSenderDoesNotTriggerSpuriousResends() Assert.True(received.IsComplete); Assert.True(received.Data.Span.SequenceEqual(frame.Data)); // 요청 수는 수신기가 보낸 패킷(트레일러까지)을 다 센 뒤에 본다 — 프레임을 받은 순간에는 트레일러가 아직 소켓에 있을 수 있다. - await rig.WaitUntilAsync(() => rig.Stream.Stats.PacketsReceived >= rig.Sender.PacketsSent); + // 끝내 다 세지 못하면 데이터그램 하나가 스트림 소켓까지 와서 세지기 전에 사라진 것이다. 그때의 요청은 그 유실을 메운 것이라 + // 군더더기는 아니지만, 이 시험은 그것도 실패로 남긴다: 윈도우 루프백에서 짧은 수신 타임아웃이 IOPending 으로 끝날 때 + // 데이터그램이 사라지는 것을 따로 쟀고, 패킷 사이마다 수신 타임아웃이 도는 이 시험이 그 유실이 드러나는 자리다. + // 기다리는 대신 실패 메시지가 두 경우(수신 쪽 유실 / 군더더기 요청)를 가른다. + 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. On Windows loopback a blocking receive whose short timeout ends in IOPending has been measured to lose " + + "the datagram (GevStream.Receiver.cs HandleReceiveError)."); + } Assert.Equal(0, rig.Resend.RequestCount); Assert.Equal(0, rig.Stream.Stats.ResendRequests); } From 2b7e807b8b19a27ea2fd7e7ca9d29933713bce6d Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 16:22:21 +0900 Subject: [PATCH 45/48] =?UTF-8?q?=EC=88=98=EC=8B=A0=20=EB=8C=80=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=86=8C=EC=BC=93=20=EC=88=98=EC=8B=A0=20=EC=8B=9C?= =?UTF-8?q?=ED=95=9C=20=EB=8C=80=EC=8B=A0=20Poll=20=EB=A1=9C=20=ED=95=B4?= =?UTF-8?q?=20=EC=9C=88=EB=8F=84=EC=9A=B0=EC=97=90=EC=84=9C=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EA=B7=B8=EB=9E=A8=EC=9D=84=20=EC=9E=83?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수신 스레드는 프레임 조립 중 2 ms 급, 한가할 때 200 ms 간격으로 깨어나 구멍을 보는데, 그 기다림을 블로킹 수신 + 소켓 수신 시한(SO_RCVTIMEO)으로 만들었다. 윈도우는 시한이 만료된 블로킹 수신 뒤 소켓 상태를 '정해지지 않음' 으로 밝히고 있고, 실제로 만료 순간 막 도착한 데이터그램이 사라진다. 루프백 부하 프로브에서 잃은 수가 IOPending 반환 수와 같았고, 스트림 시험 하나가 부하 아래 실제 수신 경로에서 데이터그램 하나를 잃었다(48회 중 1회). 실기 재현(벤치 Basler acA2500-14gm, Mono8 2592x1944, SCPD 300000 틱 ≈ 패킷 간 2.4 ms, 재전송 끔, 동시 테스트 실행 3개로 CPU 부하, 60 초): 옛 대기(0.3.0 CLI) 불완전 9/46·유실 10, 8/39·유실 9 → 이 판 0/39·0, 0/39·0. 최대 속도(패킷이 붙어 와 2 ms 틈이 없다)에서는 전후가 같다: 120 초 1752 장, 989,880 패킷, 14.59 fps, 재전송 요청 0. 재전송이 켜져 있으면 이 유실은 요청 한 번으로 가려져 왔다. 고침: 받을 데이터가 있는 동안은 논블로킹 Receive(out SocketError, 예외 없음) 한 번으로 받고, 비었을 때만 Poll 로 기다린다 — Poll 은 데이터를 건드리지 않으므로 경계에서 잃을 것이 없다. 흐르는 동안의 호출 수는 전과 같다. IOPending 은 이제 나오지 않아야 하지만 나오면 기다림이 끝난 것으로 다룬다. 오류 분류는 예외·오류 코드 양쪽에서 같은 함수로. 루프백 부하 시험 ReceiveWaitLossTests(GEVSHARP_STRESS=1)를 남긴다 — 옛 코드에서 2,800 프레임 동안 재현하지 못했으므로 근거는 실기 표이고, 그 사실을 시험 주석과 evaluation.md 에 적었다. architecture.md 수신기 설계 문단과 evaluation.md('cosmetic' 이라 적었던 IOPending 건 정정 + 측정 절)를 고쳤다. --- docs/architecture.md | 10 +- docs/evaluation.md | 35 ++++++- src/GevSharp/Gvsp/GevStream.Receiver.cs | 83 ++++++++++------- .../Gvsp/ReceiveWaitLossTests.cs | 91 +++++++++++++++++++ 4 files changed, 180 insertions(+), 39 deletions(-) create mode 100644 tests/GevSharp.Tests/Gvsp/ReceiveWaitLossTests.cs diff --git a/docs/architecture.md b/docs/architecture.md index c3c58aa..1e428e3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -385,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 diff --git a/docs/evaluation.md b/docs/evaluation.md index aefc455..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 diff --git a/src/GevSharp/Gvsp/GevStream.Receiver.cs b/src/GevSharp/Gvsp/GevStream.Receiver.cs index 0794d55..a017680 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; @@ -61,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; @@ -253,7 +252,7 @@ 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; @@ -277,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; @@ -290,18 +288,34 @@ private void ReceiveLoop() try { + // 기다림은 소켓 수신 시한(SO_RCVTIMEO)이 아니라 Poll 로 한다. 시한을 건 블로킹 수신은 윈도우에서 만료되는 순간 막 도착한 + // 데이터그램을 잃을 수 있다 — 만료 뒤 소켓 상태는 정해지지 않는다고 플랫폼이 밝히고 있고, 루프백 부하 시험에서 잃은 수가 + // IOPending 반환 수와 같았다. 그래서 받을 것이 있을 때만 논블로킹으로 받고(흐르는 동안은 호출 하나로 끝난다), 비었을 때만 + // Poll 로 기다린다. Poll 은 데이터를 건드리지 않고 기다리기만 하므로 경계에서 잃을 것이 없다. + 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 = (_activeCount > 0 ? _activeWaitMs : 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) @@ -309,6 +323,12 @@ private void ReceiveLoop() break; } + if (error != SocketError.Success) + { + if (_isStopRequested || !HandleReceiveError(error, null)) break; + continue; + } + _consecutiveReceiveErrors = 0; OnPacket(length, Stopwatch.GetTimestamp()); } @@ -338,24 +358,27 @@ private void ReceiveLoop() /// private void MarkReceiverEnded() => Interlocked.CompareExchange(ref _state, StateFaulted, StateStarted); - /// 수신 오류 분류. 계속 돌아도 되면 true, 루프를 끝내야 하면 false. - private bool HandleReceiveError(SocketException ex) + /// 기다림이 아무것도 받지 못하고 끝났다 — 방화벽 매핑을 살피고 조립 중인 프레임의 구멍·마감을 본다. + private void OnWaitElapsed() { - switch (ex.SocketErrorCode) + var now = Stopwatch.GetTimestamp(); + MaybePunchFirewall(now); + OnTick(now); + } + + /// 수신 오류 분류. 계속 돌아도 되면 true, 루프를 끝내야 하면 false. ex 는 예외로 온 경우에만 있다(로그용). + private bool HandleReceiveError(SocketError code, SocketException? ex) + { + 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) @@ -371,19 +394,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); @@ -391,18 +414,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) { 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}"); + } +} From bab22df16f7c318149c98cb25b4ad2f44a9ae16e Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 16:52:11 +0900 Subject: [PATCH 46/48] =?UTF-8?q?=EC=A0=9C=EC=96=B4=20=EC=B1=84=EB=84=90?= =?UTF-8?q?=EC=9D=B4=20=EC=9D=91=EB=8B=B5=20=EB=8C=80=EC=A1=B0=EC=9A=A9=20?= =?UTF-8?q?=EB=81=9D=EC=A0=90=EC=9D=84=20=EB=B0=96=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=82=B4=EC=A3=BC=EC=A7=80=20=EC=95=8A=EA=B3=A0,=20=EC=A0=95?= =?UTF-8?q?=EC=A7=80=C2=B7=EB=8B=AB=EA=B8=B0=20=EC=98=88=EC=82=B0=EC=9D=84?= =?UTF-8?q?=20=EC=B1=84=EB=84=90=EC=9D=B4=20=EC=A5=94=20=EC=8B=9C=ED=95=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A0=95=ED=95=98=EA=B2=8C=20=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GvcpChannel.DeviceEndPoint: 생성자에서 사본을 만들어도 공개 getter 가 그 사본 자체를 돌려줘, 받은 쪽이 Port 를 바꾸면 응답 대조가 따라 바뀌어 진짜 장치의 응답이 전부 남의 패킷으로 버려졌다(시험으로 재현 — 요청마다 시한 초과, ForeignPacketCount 증가). 대조·로그는 비공개 사본으로 하고 getter 는 부를 때마다 새 사본을 돌려준다. OpenAsync_EndPoint_KeepsItsOwnCopy 에 getter 로 받은 객체를 바꾸는 경우를 더했다(고치기 전 실패 확인). - 스트림 정지의 장치 쓰기 예산과 장치 닫기의 CCP 해제 예산을 호출자가 넘긴 GevDeviceOpt(열고 난 뒤에도 바뀔 수 있는 객체)가 아니라 채널이 열 때 검증해 사본으로 쥔 GVCP 시한에서 계산한다. 호출자가 옵션의 GvcpTimeoutMs 를 0 으로 바꾸면 스트림 열기가 내부 인자 이름의 예외로 실패하고, 닫기는 해제를 시도조차 하지 않게 되던 자리다. --- src/GevSharp/GevDevice.Stream.cs | 3 ++- src/GevSharp/GevDevice.cs | 3 ++- src/GevSharp/Gvcp/GvcpChannel.cs | 26 ++++++++++++------- .../Integration/DeviceLifecycleTests.cs | 7 +++++ 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/GevSharp/GevDevice.Stream.cs b/src/GevSharp/GevDevice.Stream.cs index 2f34ba6..130d928 100644 --- a/src/GevSharp/GevDevice.Stream.cs +++ b/src/GevSharp/GevDevice.Stream.cs @@ -20,7 +20,8 @@ public Task OpenStreamAsync(int streamChannel, GevStreamOpt? opt = nu } ct.ThrowIfCancellationRequested(); // 정지의 장치 전송 끄기는 닫기의 CCP 해제와 같은 고정 예산을 받는다 — 스트림은 이 장치의 응답 창을 모른다. - var stream = new GevStream(this, Gvcp, LocalAddress, opt, streamChannel, Address, ShutdownWriteBudgetMs(_opt.GvcpTimeoutMs)); + // 응답 창은 채널이 열 때 검증해 사본으로 쥔 값에서 읽는다. 호출자의 옵션 객체는 열고 난 뒤에도 바뀔 수 있다. + var stream = new GevStream(this, Gvcp, LocalAddress, opt, streamChannel, Address, ShutdownWriteBudgetMs(Gvcp.Opt.TimeoutMs)); return Task.FromResult(stream); } } diff --git a/src/GevSharp/GevDevice.cs b/src/GevSharp/GevDevice.cs index 5404ab5..15ebd61 100644 --- a/src/GevSharp/GevDevice.cs +++ b/src/GevSharp/GevDevice.cs @@ -462,7 +462,8 @@ public async ValueTask DisposeAsync() { // 닫기는 오래 붙들지 않는다 — 채널의 재시도 예산 전부가 아니라 짧은 고정 예산만 준다. // 놓지 못해도 장치는 자기 하트비트 타임아웃으로 알아서 푼다. - var releaseBudgetMs = ShutdownWriteBudgetMs(_opt.GvcpTimeoutMs); + // 응답 창은 채널이 쥔 사본에서 — 호출자의 옵션 객체는 열고 난 뒤에도 바뀔 수 있다(0 으로 바꾸면 해제를 시도조차 안 하게 된다). + var releaseBudgetMs = ShutdownWriteBudgetMs(Gvcp.Opt.TimeoutMs); using var releaseCts = new CancellationTokenSource(releaseBudgetMs); try { diff --git a/src/GevSharp/Gvcp/GvcpChannel.cs b/src/GevSharp/Gvcp/GvcpChannel.cs index e82eb36..e161207 100644 --- a/src/GevSharp/Gvcp/GvcpChannel.cs +++ b/src/GevSharp/Gvcp/GvcpChannel.cs @@ -95,8 +95,8 @@ public GvcpChannel(IPEndPoint device, IPAddress? localAddress = null, GvcpChanne // 다시 쓰면(Port·Address 변경) 송신 주소(아래 직렬화 사본)는 그대로인데 응답 대조(HandlePacket)·DeviceEndPoint·로그만 // 따라 바뀌어 진짜 장치의 응답이 전부 남의 패킷으로 버려진다. 아래는 전부 이 사본에서 끌어낸다. device = new IPEndPoint(device.Address, device.Port); - DeviceEndPoint = device; - _logSrc = $"{LogSrc} {DeviceEndPoint.Address}"; + _device = device; + _logSrc = $"{LogSrc} {_device.Address}"; // 호출자가 준 인스턴스를 그대로 쥐지 않고 값만 옮겨 온다 — 채널이 세션에 맞춰 상한을 다시 정할 때(SetMaxPendingAckWaitMs) // 호출자의 객체를, 나아가 같은 객체로 만든 다른 채널까지 조용히 바꿔 놓지 않기 위해서다. // ⚠ GvcpChannelOpt 에 항목을 더하면 여기에도 더한다. @@ -141,8 +141,14 @@ public GvcpChannel(IPEndPoint device, IPAddress? localAddress = null, GvcpChanne 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; @@ -209,7 +215,7 @@ public async Task RequestAsync(GvcpCmd cmd, CancellationToken ct = defa if (Interlocked.Read(ref pending.PendingDeadlineMs) > 0) { var expired = new GevTimeoutException( - $"{cmd.Name} to {DeviceEndPoint} was answered with PENDING_ACK but never completed within its {pending.BudgetMs} ms budget; " + $"{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; @@ -220,7 +226,7 @@ public async Task RequestAsync(GvcpCmd cmd, CancellationToken ct = defa 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 { @@ -240,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); } } @@ -351,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); } } @@ -470,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; } diff --git a/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs b/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs index f6fba5a..d9c4f4b 100644 --- a/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs +++ b/tests/GevSharp.Tests/Integration/DeviceLifecycleTests.cs @@ -558,6 +558,13 @@ public async Task OpenAsync_EndPoint_KeepsItsOwnCopy_SoReusingTheCallersEndPoint // 진단에 드러나는 주소(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] From 34a874de54172c1c3babbd0c92655a8f71f0273e Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 16:52:12 +0900 Subject: [PATCH 47/48] =?UTF-8?q?Poll=20=EC=88=98=EC=8B=A0=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=EC=9D=98=20=EB=92=B7=EC=A0=95=EB=A6=AC=20=E2=80=94=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0=20=EA=B0=84=EA=B2=A9=20=EC=83=81=ED=95=9C,?= =?UTF-8?q?=20=EB=8B=AB=ED=9E=8C=20=EC=86=8C=EC=BC=93=EC=9D=98=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=EC=82=AC=EC=9C=A0,=20.NET=20Framework=20=ED=95=A0?= =?UTF-8?q?=EB=8B=B9=20=EA=B8=B0=EB=A1=9D,=20=EC=98=9B=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20=EA=B0=80=EB=A6=AC=ED=82=A4=EB=8D=98=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 조립 중 대기 간격은 옵션(InitialPacketTimeoutMs·PacketTimeoutMs)에서 오고 상한이 없어, 매우 큰 값이면 마이크로초 환산이 int 를 넘쳐 음수·임의 값이 됐다(Unix 에서는 무한 대기, 방화벽 유지·마감 점검이 멈춤). 한가할 때 간격(200 ms)으로 묶는다. - 정지 없이 소켓이 닫히면 플랫폼에 따라 Poll 이 예외 대신 참을 돌려주고 다음 수신이 ObjectDisposedException 으로 끝나는데, 그 경로가 종료 사유를 채우지 않아 소비자에게 'stream socket receive failed (Success)' 가 갔다. 그 경로에서 NotSocket 을 남기고, 수신 스레드 사망 시험에 사유 문구가 '(Success)' 가 아님을 단정했다. KillSocketForTest 문서에 어느 갈래로 오는지는 플랫폼이 정한다고 적었다. - .NET Framework(netstandard2.0 자산)의 Socket.Poll 은 호출마다 약 40 B 를 할당한다(검토자 실측, net6/net8 은 0 B). 소켓이 빌 때마다 부르므로 최대 속도에서는 대략 패킷당 한 번이다. 데이터그램을 잃던 옛 무할당 대기 대신 이 비용을 택했다는 것을 수신 루프 주석과 design-requirements.md R12 상태에 적었다. - 수신 루프 주석이 근거로 '루프백 부하 시험' 을 들었는데 커밋된 부하 시험은 재현하지 못했다 — 근거를 실기 표로 바로잡았다. SlowSender 시험의 실패 문구와 GevStream 의 정지 설명이 없어진 블로킹 수신을 가리키던 것도 고쳤다. net8.0 2028건 3회·net48 1757건 통과, --no-incremental 경고 0. --- docs/design-requirements.md | 2 +- src/GevSharp/Gvsp/GevStream.Receiver.cs | 14 +++++++++++--- src/GevSharp/Gvsp/GevStream.cs | 7 ++++--- tests/GevSharp.Tests/Gvsp/GevStreamTests.cs | 14 ++++++++------ 4 files changed, 24 insertions(+), 13 deletions(-) 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/src/GevSharp/Gvsp/GevStream.Receiver.cs b/src/GevSharp/Gvsp/GevStream.Receiver.cs index a017680..90c3224 100644 --- a/src/GevSharp/Gvsp/GevStream.Receiver.cs +++ b/src/GevSharp/Gvsp/GevStream.Receiver.cs @@ -289,9 +289,12 @@ private void ReceiveLoop() try { // 기다림은 소켓 수신 시한(SO_RCVTIMEO)이 아니라 Poll 로 한다. 시한을 건 블로킹 수신은 윈도우에서 만료되는 순간 막 도착한 - // 데이터그램을 잃을 수 있다 — 만료 뒤 소켓 상태는 정해지지 않는다고 플랫폼이 밝히고 있고, 루프백 부하 시험에서 잃은 수가 - // IOPending 반환 수와 같았다. 그래서 받을 것이 있을 때만 논블로킹으로 받고(흐르는 동안은 호출 하나로 끝난다), 비었을 때만 + // 데이터그램을 잃을 수 있다 — 만료 뒤 소켓 상태는 정해지지 않는다고 플랫폼이 밝히고 있고, 실기에서 패킷 간격이 대기 + // 간격보다 넓을 때 옛 대기가 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; } @@ -304,7 +307,9 @@ private void ReceiveLoop() length = socket.Receive(_scratch, 0, _scratch.Length, SocketFlags.None, out error); if (error == SocketError.WouldBlock) { - var waitMicros = (_activeCount > 0 ? _activeWaitMs : IdleWaitMs) * 1000; + // 조립 중 대기 간격은 옵션에서 오므로 상한이 없다 — 한가할 때의 간격으로 묶어 마이크로초 환산이 넘치지 않게 하고, + // 매우 긴 시한을 준 경우에도 방화벽 유지·마감 점검이 그 간격으로는 돈다. + var waitMicros = Math.Min(_activeCount > 0 ? _activeWaitMs : IdleWaitMs, IdleWaitMs) * 1000; if (!socket.Poll(waitMicros, SelectMode.SelectRead)) { OnWaitElapsed(); @@ -320,6 +325,9 @@ private void ReceiveLoop() } catch (ObjectDisposedException) { + // 정지가 아닌데 소켓이 닫혔다 — 사유를 "성공" 으로 남기지 않는다(플랫폼에 따라 Poll 이 예외 대신 참을 돌려주고 + // 다음 수신에서 여기로 온다). + if (!_isStopRequested) _receiveExitError = SocketError.NotSocket; break; } diff --git a/src/GevSharp/Gvsp/GevStream.cs b/src/GevSharp/Gvsp/GevStream.cs index 46ba3e5..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 { @@ -298,7 +298,7 @@ public async Task StopAsync(CancellationToken ct = default) _thread = null; if (thread is not null && thread.IsAlive) { - // 상한 없이 기다리지 않는다. 소켓을 닫으면 블로킹 수신이 깨어나는 것이 보통이지만 그것을 보장하는 규격은 없고, + // 상한 없이 기다리지 않는다. 소켓을 닫으면 수신 대기(Poll)가 깨어나는 것이 보통이지만 그것을 보장하는 규격은 없고, // 여기서 무한히 기다리면 정지가 영영 돌아오지 않는다. 게다가 이 대기는 스레드풀 스레드를 하나 붙들고 있어서, // 코어가 적은 기계에서 정지가 몇 개 겹치면 풀이 고갈된다. 제어 채널은 이미 같은 상한을 두고 있다. // 시한을 넘겨도 할 일은 그대로 한다 — 소켓은 이미 닫혔고 아래에서 큐를 비워 버퍼를 돌려준다. @@ -588,7 +588,8 @@ private bool SendPunch(Socket socket) /// /// 정지 요청 없이 스트림 소켓을 닫는다 — NIC 가 내려가 소켓이 죽은 것과 같은 자리를 만들어, 수신 스레드가 - /// 스스로 끝나는 실제 경로(수신 오류 → 루프 종료 → 큐 닫기)를 밟게 한다. + /// 스스로 끝나는 실제 경로(대기·수신 실패 → 루프 종료 → 큐 닫기)를 밟게 한다. 어느 갈래로 오는지는 플랫폼이 정한다 — + /// 대기(Poll)가 예외를 내면 수신 오류 분류로, 참을 돌려주면 다음 수신의 ObjectDisposedException 으로 온다. /// internal void KillSocketForTest() => _socket?.Close(); diff --git a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs index ebbc969..be8956d 100644 --- a/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs +++ b/tests/GevSharp.Tests/Gvsp/GevStreamTests.cs @@ -500,8 +500,10 @@ public async Task ReceiverThreadDyingOnItsOwnClearsIsStarted() // 이미 큐에 든 장은 그대로 받아 갈 수 있고, 그 다음에 닫힘이 나온다. using (var queued = await rig.ReceiveAsync()) Assert.Equal(1UL, queued.FrameId); - await Assert.ThrowsAsync(() => rig.Stream.ReceiveAsync(Ct).AsTask().WaitAsync(TimeSpan.FromSeconds(10), Ct)); + 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); // 스스로 끝난 스트림은 멈춘 것이 아니라 정리를 기다리는 것이다 — 다시 시작할 수는 없고, // 정지를 불러야 장치 전송이 꺼지고 버퍼가 돌아온다. @@ -673,9 +675,9 @@ public async Task SlowSenderDoesNotTriggerSpuriousResends() Assert.True(received.Data.Span.SequenceEqual(frame.Data)); // 요청 수는 수신기가 보낸 패킷(트레일러까지)을 다 센 뒤에 본다 — 프레임을 받은 순간에는 트레일러가 아직 소켓에 있을 수 있다. // 끝내 다 세지 못하면 데이터그램 하나가 스트림 소켓까지 와서 세지기 전에 사라진 것이다. 그때의 요청은 그 유실을 메운 것이라 - // 군더더기는 아니지만, 이 시험은 그것도 실패로 남긴다: 윈도우 루프백에서 짧은 수신 타임아웃이 IOPending 으로 끝날 때 - // 데이터그램이 사라지는 것을 따로 쟀고, 패킷 사이마다 수신 타임아웃이 도는 이 시험이 그 유실이 드러나는 자리다. - // 기다리는 대신 실패 메시지가 두 경우(수신 쪽 유실 / 군더더기 요청)를 가른다. + // 군더더기는 아니지만, 이 시험은 그것도 실패로 남긴다: 옛 수신 대기(블로킹 수신 + 수신 시한)는 윈도우에서 시한 만료 순간 막 + // 도착한 데이터그램을 잃었고(실기로 잼), 패킷 사이마다 대기가 한 번씩 끝나는 이 시험이 그런 유실이 드러나는 자리다. + // 지금의 대기(논블로킹 수신 + Poll)에서는 나오지 않아야 한다. 실패 메시지가 두 경우(수신 쪽 유실 / 군더더기 요청)를 가른다. try { await rig.WaitUntilAsync(() => rig.Stream.Stats.PacketsReceived >= rig.Sender.PacketsSent, 2000); @@ -686,8 +688,8 @@ public async Task SlowSenderDoesNotTriggerSpuriousResends() 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. On Windows loopback a blocking receive whose short timeout ends in IOPending has been measured to lose " - + "the datagram (GevStream.Receiver.cs HandleReceiveError)."); + + "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); From 92594c7b96b87153919e01eeb01c571cb69e2a29 Mon Sep 17 00:00:00 2001 From: tijin Date: Sat, 26 Sep 2026 16:54:58 +0900 Subject: [PATCH 48/48] =?UTF-8?q?0.5.0=20=EC=9C=BC=EB=A1=9C=20=ED=8C=90=20?= =?UTF-8?q?=EB=B2=88=ED=98=B8=EB=A5=BC=20=ED=99=95=EC=A0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 릴리스 PR 의 마지막 커밋. 공개 API 가 늘어 minor 다 — GevDevice.OpenAsync(IPEndPoint). 이 판의 첫 변경은 윈도우에서 패킷 간격이 넓을 때 수신 대기가 데이터그램을 잃던 것을 고친 것이다(실기: 불완전 9/46·8/39 → 0/39·0/39). --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 0f87d14..f2f2e8c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,7 +7,7 @@ 내지 않는다 — 다음 판에 같이 나간다). 올린 번호는 다시 쓰지 않는다 — NuGet 은 지울 수도 덮어쓸 수도 없다. publish.yml 이 태그와 이 값이 같은지, 태그 커밋이 main 에 있는지 검사하고, 태그가 아닌 ref 에서는 올리지 않는다. --> - 0.4.2-dev + 0.5.0 kintaein Apache-2.0