Conversation
완성 판정(R29)이 막는 '장치가 블록을 끊는' 경우가 실기에서 나는지는 미관측으로 남아 있었다. 하류(CvInspect 어댑터 경유, 0.4.1 게시 패키지)가 벤치 Basler acA2500-14gm 에서 단발 그랩·라이브·1 ms 시한 그랩(프레임이 오는 도중 정지)·호출 직후 취소를 돌려 84 완성 0 불완전을 보고했다 — 이 기종은 정지할 때 보내던 블록을 끝까지 보낸다. 측정 층(CLI 하네스가 아니라 소비자 어댑터)과 출처를 절 머리에 밝히고, 끊긴 블록은 여전히 루프백 테스트로만 덮인다는 것과 다른 기종은 미측정이라는 범위를 함께 적는다. 문서만.
앞 커밋은 하류의 첫 실행(불완전 0)을 근거로 '이 Basler 는 정지 때 블록을 끝까지 보낸다' 고 적었다. 같은 절차의 둘째 실행에서 취소 반복 중 블록 55 가 431 패킷(리더가 알린 5,038,848 중 3,856,896 바이트)에서 트레일러로 끝나 불완전으로 닫혔다 — R29 가 막는 경우의 첫 실기 관측이고, 0.4.0 이면 이전 프레임 픽셀을 단 채 완성으로 나갔을 장이다. 한 번 잰 0 을 성질로 적은 것을 '간헐적' 으로 고치고, 같은 실행에서 다음 단발 그랩이 한 번 2 초 시한을 넘긴 것(수신기가 다음 블록을 놓친 것인지 장치가 늦게 시작한 것인지 하류가 0.4.0 대조군으로 가르는 중)을 함께 적는다. 수치는 하류 측정. 문서만.
하류가 같은 어댑터 소스·절차(호출 직후 취소 ×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)와 장치가 왜 끊는지는 안 쟀다는 것을 함께 적는다. 수치는 하류 측정. 문서만.
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 으로 셈해 응답 창 하나로 떨어지고 경고가 난다. 상한 검증은 넣지 않는다 — 다른 시간·횟수 옵션도 하한만 검증한다. 두 경우 모두 고치기 전 코드에서 실패하는 시험을 먼저 두고 확인했다.
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 는 고치기 전후가 같다.
GevDeviceOpt.MaxPendingAckWaitMs 는 0 을 받아들이면서 그 뜻을 적지 않아 "상한 없음" 이나 "PENDING_ACK 끄기" 로 읽힐 수 있었다. 실제 뜻은 연장이 0 이라는 것이다 — 장치가 PENDING_ACK 로 답한 명령은 응답 창(GvcpTimeoutMs) 하나 안에 끝나야 하고, 못 끝나면 GvcpRetries 와 무관하게 다시 보내지 않고 GevTimeoutException 으로 끝난다. 응답 창 안에 온 본 응답은 그대로 받는다. GevDeviceOpt·GvcpChannelOpt 의 문서와 architecture.md 의 옵션 설명에 이것을 적는다. 동작은 바꾸지 않는다. 뜻은 코드를 읽는 데서 그치지 않고 루프백 응답기로 실행해 확인했고 그 시험을 남긴다: 0 을 명시하면 자동 계산으로 바뀌지 않고, PENDING_ACK 뒤 곧바로 온 본 응답은 성공하며, PENDING_ACK 만 보내고 멈춘 명령은 재시도 3 에서도 한 번만 보내고 시한 초과로 끝난다. 같은 설정의 무응답 명령이 네 번 보내지는 대조군을 같은 시험에 두어 "한 번" 이 재시도가 꺼진 탓이 아님을 가른다. "0 = 상한 없음" 회귀(예고된 60 s 대기)는 넉넉한 시간 상한으로 잡는다.
형의 문서는 "GVCP 요청이 재시도까지 전부 응답 없이 끝났다" 한 가지만 약속했지만, 실제로는 세 자리에서 난다. GVCP 무응답(재전송까지 전부) 말고도, 장치가 PENDING_ACK 로 받아서 실행 중이라고 답한 뒤 허락된 연장 안에 끝내지 못했을 때(GvcpChannel.RequestAsync — 재전송하지 않는다), 그리고 카메라 XML 의 HTTP 다운로드가 HttpTimeoutMs 를 넘겼을 때(GevXmlLoader, GVCP 와 무관)다. 둘째 경우는 장치가 명령을 이미 실행했을 수 있어 "시한 초과 = 닿지 않았으니 다시 보내도 된다" 로 다루는 호출자가 명령을 두 번 실행할 수 있다. 그 차이를 형 문서에 적고, HTTP 경우는 URL 폴백을 도는 적재에서 GevException 의 InnerException 으로 올 수 있다는 것, System.TimeoutException 이 아니라 GevException 에서 파생한다는 것도 적는다. architecture.md 의 RequestAsync 설명에도 PENDING_ACK 뒤에는 재전송하지 않는다는 예외를 더한다. 동작은 바꾸지 않는다.
GevDevice 의 IGevPort.ReadAsync 문서는 "32비트를 넘는 주소는 GevException" 이라고 했지만, 구현은
하위 32비트로 좁히고 주소마다 경고를 한 번 남긴 뒤 접근하며, 좁힌 범위의 끝이 32비트 공간을 넘을 때만
던진다. 어느 쪽이 맞는지는 이미 정해져 있다 — 벤더 XML 이 32비트를 넘는 주소를 리터럴로 적고
(파일 접근 기준주소 0xFFFFD0000000) 실장치에서 하위 32비트(0xD0000000)가 유효한 레지스터임을 확인했으며,
protocol-notes.md·architecture.md 와 기존 시험이 그 동작을 적고 있다. 좁히기는 경고를 남기므로 조용하지 않다.
그래서 동작은 두고 메서드 문서와 IGevPort 인터페이스의 모호한 문장("범위를 벗어나면 예외")을 고친다.
기존 시험은 좁히기의 성공 경로와 좁은 주소의 끝 넘침만 덮고 있어, 넓은 주소가 좁혀진 뒤 끝이 넘치는
경우(0xFFFFFFFFFFFFFFFE+4 등)도 아무것도 보내기 전에 거절되는지 시험으로 못 박는다.
인터페이스마다 만드는 탐색 소켓은 using 블록 밖에서 만들어졌다. 바인드나 옵션 설정이 SocketException 으로 실패하면 경고만 남기고 돌아와, 소켓 핸들이 GC 종료자가 돌 때까지 남았다. 탐색을 부를 때마다 묶이지 않는 인터페이스(사라진 주소·터널 어댑터 등) 수만큼 쌓인다. 실패한 소켓을 catch 에서 닫고, SocketException 이 아닌 예외로 빠져나갈 때도 닫은 뒤 다시 던진다. 회귀: 묶일 수 없는 주소(192.0.2.1)를 인터페이스로 500 번 준 탐색 한 번 뒤 열린 핸들 수를 GC 를 막은 채 잰다. 고치기 전 +533(전체 GC 뒤 +35 로 내려가 차이가 버려진 소켓임을 보인다), 고친 뒤 +33. 브로드캐스트를 끄고 루프백의 닫힌 포트만 대상으로 두어 호스트 밖으로 나가는 것은 없다. 핸들 수는 프로세스 전역이라 격리 컬렉션에서 돈다.
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).
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").
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 로 답하는 설정을 더했다. 이 경우는 지금까지 시험이 없었다(동작 변경이 아니라 문서화한 계약을 고정하는 테스트다).
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 적재 절에 규칙을 적었다.
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 의 무효화 절에 규칙을 적었다.
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 은 자기 소거 비트를 뜻한다" 는 프로토콜 사실처럼 적혀 있어 이 라이브러리의 해석이라고 고쳤다. 완료 규칙 자체는 근거 자료 없이 바꾸지 않았다.
TLParamsLocked 가 정수 노드가 아닌 기술에서도 "노드맵에 없다" 는 Debug 한 줄만 남아, 획득 커맨드가 잠긴 채 남은 이유를 로그에서 찾을 수 없었다. 노드가 없는 경우는 지금처럼 Debug, 같은 이름의 노드가 정수가 아닌 경우는 노드 종류를 적은 Warn 으로 나눈다. 반환값·예외 동작은 그대로다. architecture.md 의 API 목록이 반환형을 Task 로 적고 있던 것을 Task<bool> 로 바로잡고, false 가 나오는 두 조건을 목록과 본문에 적는다. 두 경우를 시뮬레이터에 자작 XML 을 실어 로그 수준·문구로 못 박는 시험을 더한다.
…약에 적는다 수신 소켓이 회복 불가로 실패하면 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·노드맵은 닫힌 뒤에도 캐시에서 돌려준다는 것을 실행으로 확인해 함께 적고 시험으로 못 박는다.
시뮬레이터는 부트스트랩 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·값·주파수를 다루며 두 이름 가족이 같은 레지스터를 보는지 확인하는 시험을 더한다.
시뮬레이터 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 의 표와 제약 절을 이에 맞춘다.
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 와의 차이를 적는다.
시뮬레이터 응답기는 req_id 를 기억하지 않아 같은 req_id 로 다시 온 명령도 다시 실행한다 — 라이브러리는 응답 창 안에 ACK 가 없으면 같은 req_id 로 재전송하므로, 느린 왕복에서는 자기 소거 명령이 두 번 돈다(단발 그랩에 프레임 하나가 더 나오는 식). 이 사실과, 제품 기본 타이밍 (GvcpTimeoutMs 500·재시도 3, 하트비트 타임아웃 3000 → 주기 1000)이 굶주린 러너에서 시뮬레이터를 상대로 실패했다는 기록이 시험 리그 주석에만 있고 공개 문서에는 없었다. sim-register-map.md 에 "중복 억제 없음" 항목과 "시뮬레이터 상대 시험의 호스트 타이밍" 절을 더해, SimRig 가 쓰는 값(3000/1, 10 000/500)과 그 이유, 하류 시험도 두 설정을 넓혀야 한다는 것을 적는다. 재실행 동작은 같은 바이트·같은 req_id 의 래치 명령을 두 번 보내 두 번째가 더 늦은 값을 싣는 것으로 실행해 확인했고, 그 시험을 남긴다. 동작 변경은 없다.
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 줄에 토큰의 의미를 적는다.
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 문서에 소켓 생성·바인드 실패도 같은 결말임을 적는다.
스트림 소켓이 죽어(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 줄에 이 경로를 적는다.
리더 뒤에 곧바로 패킷 id 1 의 트레일러가 오면(장치가 첫 페이로드를 보내기 전에 블록을 끊었다) 트레일러가 정한 페이로드 수는 0 이다. 끊긴 블록 판정(IsCutShort)이 "패킷 수 > 0" 을 요구해 이 0 을 "아직 모름" 으로 읽었고, 그래서 프레임은 보존 시간 내내 뒤 프레임을 막은 채 기다렸으며, 끊긴 블록 경고도 나가지 않았고, 불완전 프레임을 받겠다고 한 소비자에게도 끝내 전달되지 않았다(전달 조건도 같은 "패킷 수 > 0" 이었다). 트레일러가 정한 0 은 확정된 값이므로 IsCutShort 에서 그 조건을 뺀다 — 한 패킷이라도 받은 뒤 끊긴 블록과 같은 경로(즉시 닫기, 경고 한 번, 불완전 통계, FrameDropped)를 탄다. 전달 조건은 트레일러가 있고 리더가 크기를 알린 경우에도 열어, 다른 끊긴 블록처럼 전부 0 으로 채운 불완전 프레임으로 나간다. 청크가 붙어 크기를 모르는 프레임은 그대로 보존 시간 경로에 남는다. 회귀: BlockCutBeforeItsFirstPayloadClosesAtOnceAsIncomplete(보존 시간 30 초에서 3 초 안에 0 으로 채운 불완전 프레임이 나오는지 — 고치기 전 시한 초과), BlockCutBeforeItsFirstPayloadIsReportedLikeAnyCutBlock(경고 한 줄 — 고치기 전 FrameDropped 조차 없어 시한 초과). 로그 테스트는 전역 싱크를 바꾸므로 격리 컬렉션의 새 클래스에 둔다.
페이로드는 (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".
노출이 긴 촬영에서는 리더가 먼저 오므로, 리더만 받은 가장 새 프레임은 보존 시간(FrameRetentionMs)으로도 닫지 않는다. 그 사이 장치가 그 블록을 버리고(정지) 촬영을 다시 시작해 같은 블록 번호로 새 리더를 보내면 — 촬영마다 번호를 1 부터 다시 세는 장치가 있다 — 슬롯은 블록 번호로만 찾으므로 새 리더가 중복으로 버려지고, 새 페이로드가 옛 리더의 슬롯에 실려 옛 타임스탬프·기하로 완성 처리됐다. 바이트 수가 같으면 IsComplete=true 로 나와 틀린 값이 정상처럼 보인다. 리더만 받은 프레임을 보존 시간으로 닫는 쪽으로는 고치지 않는다 — 그 예외가 긴 노출 장치를 위해 있다. 대신 옛 프레임에 페이로드도 트레일러도 없고, 재요청 간격(PacketTimeoutMs) 이상 조용했으며, 새 리더가 리센드 사본이 아니고 타임스탬프가 옛 리더와 같지 않으면(같으면 늦은 사본이다) 장치가 블록을 다시 시작한 것으로 보고 같은 슬롯을 새 리더로 다시 연다. 옛 프레임은 불완전 한 장으로 세고 FrameDropped 로 알리되, 받은 이미지 바이트가 없고 더 오래된 조립 중 프레임을 앞지르면 안 되므로 DeliverIncompleteFrames 여도 내보내지 않는다(옵션 문서에 적었다). 닫았다 새로 열지 않고 제자리에서 다시 쓰는 것은 닫힌 블록 기록에 옛 타임스탬프를 남겨 새 프레임의 늦은 사본을 새 프레임으로 오인하지 않기 위해서다. 재요청 간격 안에 다시 시작한 리더는 여전히 중복으로 버려진다. 회귀: RestartedBlockAfterALeaderOnlyFrameIsAssembledWithItsOwnLeader(64x100 리더 뒤 보존 시간을 넘겨 쉬고 같은 번호의 128x50 프레임 — 고치기 전 Timestamp 기대 222000, 실제 111000), LateCopyOfALeaderOnlyFramesLeaderIsStillADuplicate (같은 타임스탬프의 늦은 사본은 다시 열지 않는다 — 새 규칙의 반대편을 못 박는다). architecture.md 의 블록 번호 재시작 절에 이 경우를 적었다.
리더가 알린 패킷을 다 받았는데 받은 끝이 리더 크기에 못 미치고(크기 규칙이 장치보다 크게 센 경우 — 실기에서는 본 적 없다) 트레일러까지 잃으면, 끊긴 블록 판정은 트레일러를 요구하므로 프레임은 보존 시간까지 기다린 뒤 불완전으로 닫힌다. 동작은 바꾸지 않는다. 늦게 온 트레일러가 가변 높이의 실제 줄 수를 알리면 ApplyTrailerHeight 가 크기를 줄여 그 프레임을 완성시킬 수 있으므로, 침묵만으로 일찍 닫으면 살릴 수 있던 프레임을 버리게 된다. 트레일러는 리센드로 묻지 않으므로 끝내 안 오면 결과는 어차피 불완전이고 달라지는 것은 닫히는 시각뿐이다 — 어느 쪽이든 모자란 프레임을 완성으로 내보내지는 않는다. 이 판단을 IsCutShort 옆에 남겨 "중복 대기" 로 보고 지우지 않게 한다.
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 에 둔다.
지금까지 장치는 표준 GVCP 포트 3956 으로만 열 수 있었고, 포트를 지정하는 오버로드는 시뮬레이터 시험용 내부 멤버였다. 그래서 루프백의 시뮬레이터나 포트를 옮겨 둔 NAT·포워딩 뒤의 장치를 소비자가 공개 API 로 열 길이 없었다(하류가 장비 없는 시험을 위해 요청). 그 오버로드를 공개한다 — 공개 API 추가라 다음 판은 minor 다. 포트는 제어 채널(레지스터 접근·하트비트·리센드 요청)에만 쓰이고 스트림은 장치가 자기 설정대로 보내는 곳에서 받는다는 것을 문서에 적었다. 포트 0 은 ArgumentOutOfRangeException. ProbeAsync(IPEndPoint) 는 내부로 둔다. 테스트: 오버로드가 공개인지, 임시 포트의 시뮬레이터를 이 오버로드로 열어 레지스터를 읽는지, 포트 0 거절. architecture.md 의 API 목록과 '내부 오버로드' 서술을 고쳤다.
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 적재 규칙에 이 경우를 적었다.
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 시한 초과 항목도 바로잡았다.
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 적재 규칙에 캐시 키 읽기와 표식 판정을 적었다.
… 않게 한다 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" 로 실패했다.
Reboot 는 명령 잠금(_commandGate)을 푼 뒤에 ControlOwnerChanged(null) 을 올렸다. 그 틈에 응답기 스레드가 다른 호스트의 CCP 쓰기를 처리하면 새 보유자 알림이 먼저 나가, 관찰자는 [새 보유자, null] 을 받고 누군가 쥔 장치를 "아무도 안 쥐었다" 로 읽었다. 서버 스레드의 획득·해제·만료 알림은 이미 같은 잠금 안에서 올라가므로, 재부팅의 null 도 잠금 안에서 올리면 알림 순서가 상태가 바뀐 순서와 같아진다. 서버 스레드로 넘기지 않은 것은, 재부팅 직후 동기로 null 을 확인하는 기존 시험과 시작하지 않은 시뮬레이터에서도 알림이 나가야 하기 때문이다. 이벤트 문서가 "서버 스레드에서 호출된다" 고만 적고 있어, 재부팅의 null 은 Reboot 를 부른 스레드에서 올라간다는 것, 모든 알림이 명령 처리와 같은 잠금 안이라 순서가 보존된다는 것, 그래서 처리기가 이 장치의 GVCP 응답을 기다리면 응답기도 멈춘다는 것을 적었다. Reboot 문서와 sim-register-map.md 에도 한 구절씩 맞췄다. 시험: 앞선 관찰자가 재부팅의 null 을 받는 자리에서 다른 호스트의 CCP 쓰기를 보내고 ACK 를 잠시 기다린다. 고치기 전에는 그 대기 동안 응답기가 쓰기를 처리해 [새 보유자, null] 로 결정적으로 뒤집혔다(Assert.Equal 컬렉션 불일치).
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)과 기존 두 시험은 그대로 통과하는 것을 확인했다.
…에 맞춘다 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) 라고 밝히고, 프로브만 내부 멤버라는 것을 덧붙였다.
공개된 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(주소 계열 불일치)으로 나오는 것을 확인해 묶기 실패 문구에 포함했다. 시한 초과·제어권 거절·옵션 범위는 기존 시험이 이미 다룬다.
정지는 호출자의 토큰과 무관하게 장치 전송 끄기(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 의 정지 줄을 이 상한에 맞게 고쳤다.
리센드를 끄면(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 근처에서 닫힘)과 켜짐(보존 시간까지 기다림, 대조군)으로 한 번에 잰다.
프레임은 마지막 페이로드에서 닫혀 큐에 들므로, 다섯째 프레임을 받은 순간 그 블록의 트레일러는 아직 소켓에 있을 수 있다. 시험은 그 직후 통계를 찍어 PacketsReceived 를 보낸 패킷 수와 견주었기 때문에, 수신 스레드가 큐에 넣은 뒤 잠깐 밀리기만 해도 25 대 24 로 깨졌다. 수신 스레드가 조립 중인 프레임이 없을 때 패킷 처리 전에 5 ms 쉬게 하는 주입으로 두 경우 모두 같은 줄에서 재현했다. 이제 PacketsReceived 가 보낸 수에 닿을 때까지 기다린 뒤 찍고, 같다는 단정은 그대로 둔다. 또 하나의 오래된 경로도 막았다. 기본 재요청 간격 20 ms 에서는 송신이 프레임 도중 그만큼 멈추면 침묵 규칙이 아직 안 온 꼬리를 물어 ResendRequests 가 0 이 아니게 된다(프레임마다 30 ms 멈추는 주입으로 재현). 이 시험은 침묵 규칙도 보존 시간도 보지 않으므로 재요청 간격 2000 ms, 보존 시간 5000 ms 로 넉넉히 두었다. 프레임은 마지막 페이로드에서 닫히므로 시험은 느려지지 않는다. 두 주입 모두 고친 시험에서는 통과했다.
셋 다 수신기가 아직 따라오지 않았을 수 있는 순간에 계수기를 찍어, 러너가 밀리면 깨지던 오래된 경합이다. 단정하던 것은 그대로 두고, 기다려야 할 사건을 기다리게 했다. 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 로 올리고, 트레일러까지 수신기가 다 센 뒤에 요청 수를 본다. 프레임은 마지막 페이로드에서 닫히므로 시험은 느려지지 않는다. 세 주입 모두 고친 시험에서는 통과했고, 주입은 남기지 않았다.
앞 커밋은 이 시험의 실패를 송신 멈춤이 침묵 규칙을 부른 것으로 적고, 트레일러까지 다 셀 때까지 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 송신 멈춤은 주입으로 재현한 별도 경로이고 넓힌 문턱이 그것을 막는다.
수신 스레드는 프레임 조립 중 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 건 정정 + 측정 절)를 고쳤다.
- GvcpChannel.DeviceEndPoint: 생성자에서 사본을 만들어도 공개 getter 가 그 사본 자체를 돌려줘, 받은 쪽이 Port 를 바꾸면 응답 대조가 따라 바뀌어 진짜 장치의 응답이 전부 남의 패킷으로 버려졌다(시험으로 재현 — 요청마다 시한 초과, ForeignPacketCount 증가). 대조·로그는 비공개 사본으로 하고 getter 는 부를 때마다 새 사본을 돌려준다. OpenAsync_EndPoint_KeepsItsOwnCopy 에 getter 로 받은 객체를 바꾸는 경우를 더했다(고치기 전 실패 확인). - 스트림 정지의 장치 쓰기 예산과 장치 닫기의 CCP 해제 예산을 호출자가 넘긴 GevDeviceOpt(열고 난 뒤에도 바뀔 수 있는 객체)가 아니라 채널이 열 때 검증해 사본으로 쥔 GVCP 시한에서 계산한다. 호출자가 옵션의 GvcpTimeoutMs 를 0 으로 바꾸면 스트림 열기가 내부 인자 이름의 예외로 실패하고, 닫기는 해제를 시도조차 하지 않게 되던 자리다.
… 가리키던 주석 - 조립 중 대기 간격은 옵션(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.
릴리스 PR 의 마지막 커밋. 공개 API 가 늘어 minor 다 — GevDevice.OpenAsync(IPEndPoint). 이 판의 첫 변경은 윈도우에서 패킷 간격이 넓을 때 수신 대기가 데이터그램을 잃던 것을 고친 것이다(실기: 불완전 9/46·8/39 → 0/39·0/39).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed and why
Library — behaviour a consumer will notice
Pollonly when the socket is empty. At full rate nothing changes measurably (120 s, 1752 frames, 14.59 fps, 0 resend requests before and after). On .NET Framework (the netstandard2.0 asset) that wait allocates about 40 bytes per call inside the framework, about once per packet at full rate; net6 and later allocate nothing.GevDevice.OpenAsync(IPEndPoint)opens a device that answers GVCP on a non-standard port — a simulator on loopback, a device behind NAT or port forwarding. The port is used for the control channel only. The channel keeps its own copy of the end point, so reusing or changing the caller'sIPEndPoint(or the one returned byGvcpChannel.DeviceEndPoint) afterwards changes nothing.With resend off, frames dropped for other reasons now close after
PacketTimeoutMslike everything else, and the start log says whenFrameRetentionMsdoes not apply.StopAsyncalways tries to switch the device's transmission off (SCP = 0, SCDA = 0) with its own bounded budget (at most 2 s, independent of the caller's token andGvcpRetries), and a pre-cancelled token still does the full local cleanup. A failed start always ends stopped.IsStartedbecomes false when the receiver thread ends on its own.INode.Invalidate()and write invalidation now reach registers behind formula nodes (SwissKnife/IntSwissKnife/Converterinputs), so re-reading a latched value after a latch command asks the device.ICommand.IsDoneAsynchonours the command's access mode; its completion rule is documented precisely (it keys on the command's ownPollingTime).GetXmlAsync/GetNodeMapAsyncnow throw device loss as its own type —GevControlLostException, a no-replyGevTimeoutException,ObjectDisposedException— instead of wrapping it in a genericGevExceptionafter trying the second URL. An HTTP timeout, or a command the device accepted with PENDING_ACK but did not finish, is not device loss and stays wrapped. A control channel that closes under an open session now raisesControlLostat once instead of leavingIsOpentrue while every call throwsObjectDisposedException.GevDiscoveryOpt.Repeat < 1now throwsArgumentOutOfRangeException(it silently became 1) and repeats fit inside the timeout window; an empty result caused by no usable interface is logged with the reason; the discovery socket is closed when binding fails.GvcpRetries = int.MaxValueno longer wraps to zero attempts;DeviceHeartbeatTimeoutMsno longer reads negative for a 0xFFFFFFFF register; the channel and device close budgets come from the validated channel timeout, not from an options object the caller can still change.Documentation
ObjectDisposedException, all threeGevTimeoutExceptioncases, whatSetTlParamsLockedAsyncreturning false means,MaxPendingAckWaitMs = 0, 64-bit addresses being narrowed with a warning onIGevPort, and the exceptions of everyOpenAsyncoverload.ProbeAsync's null reasons. Retention/packet-timeout options.docs/evaluation.md: the receive-wait measurement; blocks cut by a Basler when acquisition is stopped (reported by a consumer, first hardware case of the 0.4.1 completion rule; the 0.4.0/0.4.1 comparison showed the cut frame was delivered as complete by 0.4.0 and is reported as a timeout by 0.4.1).Simulator (in the repository, not packaged)
GevTimestampControlLatch/GevTimestampValuenames with 1 = reset, 2 = latch; acquisition-format features locked while acquiring;SimDevice.Reboot(); notes on command re-execution and test timing.Version
0.5.0 — minor: the public API grew (
GevDevice.OpenAsync(IPEndPoint)).Checks
dotnet buildin the default configuration reports 0 warningsmainis an ancestor of this branch (the release-pr-guard job verifies it)mainmerged back intodevafterwards, anddevbumped to the next-devversion