호스트 종료 코드가 Deselect.req를 보내고 응답을 기다린다. 타임아웃이 나고 로그에는 deselect timeout이 찍힌다. 그런데 장비는 멀쩡하다. 같은 소켓으로 Linktest.req를 던지면 Linktest.rsp가 곧바로 돌아온다.
아래는 passive HSMS 리스너를 상대로 소켓을 직접 열어서 잡은 캡처다. 호스트 역할로 Select.req를 보내 Select.rsp를 받고 세션을 세운 다음, 같은 소켓 위에서 SType 바이트를 하나씩 훑었다. Deselect.req(3), Linktest.req(5), Reject.req(7), E37이 not used로 비워 둔 8, 그리고 미할당인 11. 돌아온 건 Linktest뿐이다. 나머지는 전부 응답 없음이고, 장비는 그 프레임들을 하나도 빠짐없이 RX로 기록했다. 여기서 침묵은 파싱 실패가 아니라 선택이다.
캡처
하나의 소켓, 하나의 세션이다. 장비 쪽 로그에 RX로 남은 것만 골라서 옮겼다.
TX Select.req 00 00 00 0A 00 0B 00 00 00 01 00 00 04 01
RX Select.rsp 00 00 00 0A 00 0B 00 00 00 02 00 00 04 01
TX Deselect.req 00 00 00 0A 00 0B 00 00 00 03 00 00 04 02
RX (없음 — 1.5초 대기)
TX Linktest.req 00 00 00 0A 00 0B 00 00 00 05 00 00 04 03
RX Linktest.rsp 00 00 00 0A 00 0B 00 00 00 06 00 00 04 03
TX Reject.req (SType 7) 00 00 00 0A 00 0B 00 00 00 07 00 00 04 04
RX (없음 — 1.5초 대기)
TX SType 8 (not used) 00 00 00 0A 00 0B 00 00 00 08 00 00 04 05
RX (없음 — 1.5초 대기)
TX SType 11 (미할당) 00 00 00 0A 00 0B 00 00 00 0B 00 00 04 06
RX (없음 — 1.5초 대기)
TX Separate.req 00 00 00 0A 00 0B 00 00 00 09 00 00 04 07
RX (EOF — 장비가 소켓을 닫았다)
읽는 방법은 이 시리즈의 다른 캡처와 같다. 00 00 00 0A는 뒤에 10바이트가 온다는 뜻이고, 컨트롤 메시지는 본문이 없으니 항상 10이다. 00 0B가 SessionID, 마지막 4바이트가 SystemBytes다. 여기서 볼 건 열 번째 바이트, SType 하나다. 01, 03, 05, 07, 08, 0B, 09 순서로 나갔고, 무언가를 받아 온 건 01과 05뿐이다.
SystemBytes는 답이 온 둘만 그대로 돌아왔다. 00 00 04 01에 00 00 04 01, 00 00 04 03에 00 00 04 03. 04 02, 04 04, 04 05, 04 06, 04 07은 짝이 없다.
Deselect만 조용한 이유
SEMI E37의 SType 표는 캡처를 읽는 동안 옆에 펴 두는 게 좋다. 비어 있는 칸이 이 글의 전부이기 때문이다.
| SType | 메시지 | 짝이 되는 응답 |
|---|---|---|
| 0 | 데이터 메시지 | W-bit에 따라 다름 |
| 1 | Select.req | Select.rsp |
| 2 | Select.rsp | — |
| 3 | Deselect.req | Deselect.rsp |
| 4 | Deselect.rsp | — |
| 5 | Linktest.req | Linktest.rsp |
| 6 | Linktest.rsp | — |
| 7 | Reject.req | — |
| 8 | not used | — |
| 9 | Separate.req | — |
| 10–127 | 하위 표준용 예약 | — |
| 128–255 | 예약, 사용 안 함 | — |
3과 4는 분명히 짝이다. Deselect.req는 요청/응답 트랜잭션이고, 응답이 안 오면 **T6(control transaction timeout)**가 걸린다. E37 타이머 표가 T6에 주는 값은 5초다. 다만 이건 typical value지 강제 기본값이 아니고, 내가 만져 본 스택은 전부 설정으로 열어 둔다. Deselect.rsp가 왔다면 결과는 Header Byte 3, 즉 Deselect Status에 실린다. Select.rsp가 Select Status를 싣는 그 자리고, 0이 수락이다. 여기서는 그게 의미가 없다. Deselect.rsp 자체가 안 오니까. 그래도 응답을 기다리는 게 맞다.
그런데 이 리스너는 안 보낸다. 구현이 처리하는 SType이 1, 5, 9뿐이고 3은 그냥 버린다. 표준에 없는 동작이지만, 현장에서 Deselect를 구현한 장비를 만나는 일 자체가 드물다. 세션을 끝낼 때 다들 Separate.req를 쓰기 때문이다. 다만 위 캡처가 증명하는 건 이 구현 하나뿐이라는 것도 분명히 해 둔다. 다른 장비가 Deselect.rsp를 제대로 돌려주는지는 그 장비에 붙어 봐야 안다.
Separate.req 쪽 동작은 HSMS 세션을 제대로 끊는 법과 Separate.req에 응답이 없는 이유에서 따로 다뤘다. 거기서는 무응답이 정상이다. Separate.req에는 애초에 짝이 되는 rsp가 없다. Deselect.req는 다르다. 짝이 있는데 안 오는 것이다.
Linktest가 진단 도구다
무응답의 원인은 크게 둘이다. 상대가 죽었거나, 상대가 그 SType을 구현하지 않았거나. 호스트 로그만 보면 둘 다 똑같이 타임아웃 한 줄이다.
캡처가 그걸 갈라 준다. Deselect.req가 1.5초 동안 아무것도 못 받은 직후, 같은 소켓에서 Linktest.req는 1밀리초 안에 Linktest.rsp를 받았다. 소켓은 살아 있고 상대도 살아 있다. 죽은 건 그 메시지 하나다.
이게 실무에서 쓰는 방법이다. 컨트롤 메시지에 응답이 없으면 재접속부터 하지 말고 Linktest를 하나 던져 보라. 돌아오면 링크 문제가 아니다. 안 돌아오면 그때 링크를 의심하면 된다. Linktest는 통과하는데 Select가 안 끝나는 반대 경우는 Linktest는 받는데 Select는 끝내지 못하는 장비에 있다.
세션은 SELECTED로 남는다
무응답을 "요청이 반쯤 처리됐다"로 읽으면 안 된다. 같은 순서를 다시 돌리면서 장비 쪽 상태를 조회했다.
Select.req 이후 응답 있음 장비 hsmsState = SELECTED
Deselect.req 이후 응답 없음 장비 hsmsState = SELECTED
장비 입장에서는 아무 일도 없었다. 세션은 그대로다.
여기서 사고가 나는 지점은 호스트다. Deselect.req를 보내고 T6 타임아웃이 나면 자기 상태를 NOT SELECTED로 내려버리는 구현이 있다. 응답을 못 받았으면 상태 전이도 하지 말아야 하는데, 타임아웃을 "어쨌든 끝났다"로 처리한 것이다. 그러면 호스트는 NOT SELECTED, 장비는 SELECTED다. 다음 Select.req가 어떻게 처리될지는 장비 구현에 달렸고, 그때부터는 원인을 찾기 어려워진다. 상태를 확실히 내리고 싶으면 Separate.req를 보내고 소켓을 닫아라.
이걸 설명해 줬어야 할 메시지가 Reject.req다
위 캡처에서 짝이 없는 프레임 셋은 동작이 똑같다. 08은 E37 표가 유일하게 not used로 비워 둔 값이다. 0B은 E37이 하위 표준용으로 예약한 10–127 구간에 들어가니 HSMS 차원의 의미가 붙어 있지 않다. 07은 Reject.req 자체를, 리스너가 어떻게 받는지 보려고 반대로 실어 보낸 것이다. 셋 다 장비 쪽에 RX로 남았다. 프레임은 파싱됐고 SType만 처리되지 않았다는 뜻이다. 그리고 셋 다 아무것도 돌려주지 않았다.
E37이 이럴 때 쓰라고 준 장치가 Reject.req다. 지원하지 않는 SType을 받으면 SType 7로 그렇다고 답하게 되어 있다. 이것도 컨트롤 메시지라서, 이 캡처의 다른 모든 컨트롤 메시지와 똑같이 본문이 없다. 10바이트 헤더가 메시지 전부다. 설명은 데이터 메시지가 다른 용도로 쓰는 헤더 바이트 두 개에 실린다.
| 헤더 바이트 | Reject.req에서 | 참고 |
|---|---|---|
| Byte 2 | 거부 대상 SType 또는 PType | 데이터 메시지에서는 Stream |
| Byte 3 | Reason Code | 데이터 메시지에서는 Function, Select.rsp에서는 Select Status |
E37의 reason code 표는 이렇다.
| Reason Code | 뜻 | Byte 2에 실리는 것 |
|---|---|---|
| 1 | SType not supported | 문제가 된 SType |
| 2 | PType not supported | 문제가 된 PType |
| 3 | Transaction not open | 짝이 되는 .req 없이 .rsp가 왔다 |
| 4 | Entity not selected | NOT SELECTED 상태에서 데이터 메시지가 왔다 |
이 숫자들은 표준에서 옮긴 것이지 측정한 게 아니다. 이 리스너는 Reject.req를 아예 보내지 않으므로 내 캡처가 저 값들을 검증해 주지는 않는다. 숫자는 인용이고, 내가 더 확신하는 쪽은 바이트 위치다. Reason Code와 Function을 헷갈리면 디코더가 Function 번호를 기대하는 자리에 04가 들어앉는다.
그래서 내가 잘못 보낸 거부든 상대가 나에게 보낸 거부든, 로그에는 똑같이 침묵으로 남는다. 상대가 설명해 준다는 가정을 코드에 넣지 마라. 그리고 진단 로그에는 SType을 해석한 이름 말고 raw 바이트로도 남겨야 한다. Unknown control message라고만 찍힌 줄로는 0B를 보냈다는 걸 알 수 없다.
호스트 쪽에서 확인할 것
- 종료 경로가 Deselect.req를 쓰는가. 쓴다면 Deselect.rsp가 안 올 때 무엇을 하는지 확인하라.
- 컨트롤 메시지 타임아웃에서 상태를 내려버리는가. 응답 없이 상태를 바꾸면 양쪽이 어긋난다.
- 타임아웃 처리 안에 Linktest 한 번이 들어 있는가. 링크 문제와 미구현을 가르는 가장 싼 방법이다.
- 로그가 SType을 raw 바이트로 남기는가. 이름만 남기면 미정의 값이 전부 같은 줄로 뭉개진다.
- 디코더가 컨트롤 메시지에서도 Header Byte 3을 Function으로 읽는가. Select.rsp에서는 Select Status, Deselect.rsp에서는 Deselect Status, Reject.req에서는 Reason Code다.
캡처는 SECS/GEM 시뮬레이터의 passive 리스너를 상대로 만들었다. 소켓을 열고 SType 3, 7, 8, 11을 보내 보면 넷 다 같은 침묵이 나온다.