호스트 감시 화면에는 초록불이 떠 있다. Linktest가 15초마다 잘 돌아오고 있으니 연결은 살아 있다. 그런데 메시지를 하나도 못 보낸다.
HSMS에서 살아 있음과 연결됨은 다른 상태다. SEMI E37의 연결 상태 기계는 NOT CONNECTED와 CONNECTED를 두고, CONNECTED 안에 다시 NOT SELECTED와 SELECTED를 둔다. 소켓이 붙으면 CONNECTED / NOT SELECTED다. 데이터 메시지(SType 00)는 SELECTED에서만 흐른다. Select가 끝나지 않았다면 Linktest가 아무리 왕복해도 상태는 NOT SELECTED다. Linktest 하나만 보고 판단하는 헬스체크가 정확히 여기서 속는다.
캡처: Select를 보내기도 전에 Linktest가 답한다
2026-09-11, 시뮬레이터의 passive listener(127.0.0.1:5501)에 파이썬 소켓 하나를 밖에서 붙여 던진 바이트다. hex는 장비 쪽이 RX/TX로 남긴 것과 같고 시간은 호스트에서 쟀다. SessionID는 00 0B(11). 원본 export는 content/demos/hsms-linktest-alive-but-not-selected.json에 있다.
Select.req는 아직 한 번도 보내지 않았다. 상태는 NOT SELECTED다.
06:03:17.592 TX Linktest.req 00 00 00 0A 00 0B 00 00 00 05 00 00 0D 01
06:03:17.592 RX Linktest.rsp 00 00 00 0A 00 0B 00 00 00 06 00 00 0D 01 0.5 ms
앞 4바이트가 길이 접두어(0A = 10, 컨트롤 메시지는 본문이 없으니 항상 10). 이어지는 10바이트가 헤더다. 바이트 0–1이 SessionID, 바이트 2와 3은 컨트롤 메시지라 둘 다 00, 바이트 4가 PType(00 = SECS-II), 바이트 5가 SType(05 Linktest.req, 06 Linktest.rsp), 바이트 6–9가 SystemBytes 00 00 0D 01이고 그대로 돌아온다. 0.5밀리초. 소켓도 프로세스도 멀쩡하다.
여기 바이트 하나가 규격과 다르다. E37은 Linktest.req와 Linktest.rsp의 SessionID를 FF FF로 채우라고 정의한다. Linktest는 세션이 아니라 링크 자체를 보는 절차라 세션 번호를 쓸 자리가 없기 때문이다. 위 캡처는 00 0B, 일반 세션 번호가 그대로 들어가 있다. 내 클라이언트가 그렇게 보냈고 이 listener는 받은 값을 되돌려준다. 실물 장비에도 흔한 동작이지만, 호스트가 Linktest를 SessionID로 매칭하고 있다면 FF FF를 쓰는 상대를 만나는 순간 깨진다. 매칭은 SystemBytes로 한다.
이어서 같은 소켓에 데이터 메시지를 하나 던졌다. 여전히 Select 전이다.
06:03:18.793 TX S1F13 W=1 00 00 00 1A 00 0B 81 0D 00 00 00 00 0D 02
01 02 41 06 48 4F 53 54 49 44 41 04 31 2E 30 30
06:03:18.794 RX S1F14 00 00 00 37 00 0B 01 0E 00 00 00 00 0D 02
01 02 21 01 00 01 02 41 15 56 58 2D 39 30 30 30 20 50 6C 61 73 6D 61 20 45 74 63 68 65 72
41 0D 53 45 43 53 47 45 4D 2D 31 2E 34 2E 31 1.1 ms
데이터 메시지라 SType은 00이고, 바이트 2 81은 최상위 비트가 W-bit에 하위 7비트가 stream 1, 바이트 3 0D가 function 13이다. 답은 바이트 2가 01(W-bit 내려감), 바이트 3 0E가 function 14, SystemBytes 00 00 0D 02 그대로. 본문은 01 02(L[2]) 안에 21 01 00(COMMACK 0)과 A[21] VX-9000 Plasma Etcher, A[13] SECSGEM-1.4.1이다.
이건 E37 위반이다. 데이터 메시지는 SELECTED에서만 허용된다. NOT SELECTED에서 SType 00을 받은 쪽은 E37의 reject 절차대로 Reject.req(SType 07)를 돌려줘야 한다 — 헤더 바이트 3에 Reason Code 04, "Entity not selected", 바이트 2에는 거절 대상의 SType. 이 listener는 상태를 아예 보지 않고 답했다. 소스의 SType 00 분기에 상태 검사가 없다. Reject.req 헤더를 읽는 법은 Deselect.req와 답 없는 SType들에 정리해뒀다.
굳이 적는 이유는 시뮬레이터를 규격 대신 읽으면 안 되기 때문이다. 여기서 S1F14가 돌아온다고 실물 장비도 그럴 거라 생각하면 안 된다. 반대로, 현장에서 Select 전에 보낸 S1F13에 답이 왔다면 그 장비도 같은 검사를 빼먹고 있는 것이다. 호스트 스택을 짜는 쪽이라면 더 단순하다. NOT SELECTED에서는 데이터 메시지를 소켓에 쓰지 마라. 상대가 답해주는 건 운이지 계약이 아니다.
1.2초 뒤에 Select.req를 보냈다. 이제야 세션이 열린다.
06:03:19.994 TX Select.req 00 00 00 0A 00 0B 00 00 00 01 00 00 0D 03
06:03:19.994 RX Select.rsp 00 00 00 0A 00 0B 00 00 00 02 00 00 0D 03 0.7 ms
Select.rsp의 바이트 3이 Select Status다. 여기서는 00, 성공. 데이터 메시지가 function 번호로 쓰는 바로 그 자리다.
Select를 삼켜도 Linktest는 계속 답한다
장비에 silentAfterSelect 폴트를 걸고 순서를 반대로 했다.
06:02:54.271 RX Select.req 00 00 00 0A 00 0B 00 00 00 01 00 00 0B 01
06:02:54.271 FAULT Select.req ignored (silentAfterSelect)
(응답 없음, 4004 ms 대기 후 포기)
06:02:58.275 RX Linktest.req 00 00 00 0A 00 0B 00 00 00 05 00 00 0B 02
06:02:58.275 TX Linktest.rsp 00 00 00 0A 00 0B 00 00 00 06 00 00 0B 02 0.5 ms
핵심은 FAULT 줄 위의 RX다. Select.req는 장비에 도착했다. 파서를 통과했고 로그에 찍혀 있다. 그런데 답이 없다. 4초를 기다려도 없고, 바로 이어 던진 Linktest는 0.5밀리초 만에 돌아온다. 같은 장비, 같은 연결.
TCP는 붙어 있고 프로세스도 응답할 여유가 있다. 세션만 안 열렸다. 이 장비를 "정상"으로 표시하는 감시 화면은 거짓말을 하는 게 아니라 틀린 질문을 하고 있다.
현장에서 이 형태가 나오는 경우는 대개 셋 중 하나다. Select를 처리할 상태가 아니거나(초기화 중, 다른 호스트와의 세션 정리 중), SessionID를 모르거나, 이미 물려 있는 세션을 못 놓고 있거나. 어느 쪽이든 Linktest는 계속 답한다. E37에서 Linktest는 세션이 아니라 링크를 확인하는 절차고, TCP가 붙은 뒤라면 NOT SELECTED에서도 쓸 수 있다. 그래서 Linktest 성공은 SELECTED에 대해 아무것도 말해주지 않는다.
덧붙이면 뒤의 두 경우 — SessionID를 모르거나 세션이 이미 물려 있거나 — 에서는 침묵 자체가 이미 규격 위반이다. E37은 Select.req를 거절할 때도 Select.rsp를 보내되 바이트 3의 Select Status를 0이 아닌 값으로 채우라고 한다. 답이 아예 없으면 장비 쪽 버그거나 그 앞 단에서 패킷이 죽은 것이다. 벤더에 문의할 때 이 한 줄이 있고 없고가 다르다.
반대 방향 — Select는 잘 되는데 keepalive만 삼키는 장비 — 은 어느 HSMS 타이머가 터졌나에 ignoreLinktest로 실측한 캡처가 있다. Linktest.req가 RX에 찍히고 답만 사라지면서 5.005초 뒤 T6이 만료된다. 증상은 "잘 되다가 주기적으로 끊긴다"로 보고되고, 로그만 보면 네트워크 불안정과 구분이 안 된다.
먼저 터지는 타이머는 T7이 아니다
이 둘을 호스트 로그에서 가르는 실마리는 만료된 타이머의 종류다. E37이 정의하는 값과 범위는 이렇다.
| 타이머 | E37 이름 | 기본값(범위) | 무엇을 재는가 |
|---|---|---|---|
| T3 | Reply Timeout | 45초 (1–120) | W-bit을 세운 데이터 메시지의 응답 대기 |
| T6 | Control Transaction Timeout | 5초 (1–240) | Select·Deselect·Linktest 트랜잭션이 열려 있는 시간 |
| T7 | NOT SELECTED Timeout | 10초 (1–240) | TCP 연결이 NOT SELECTED에 머무는 시간 |
위 silentAfterSelect 캡처에서 먼저 터지는 건 T7이 아니라 T6다. Select.req를 보낸 쪽에는 열린 컨트롤 트랜잭션이 있고, 그걸 재는 게 T6다. 기본값대로면 5초 대 10초, T6이 먼저다. T7은 그 뒤에 오는 백스톱이고, 실제로 T7을 돌리는 쪽은 대개 Select.req를 기다리는 passive 장비다. 세 타이머가 다 timeout 한 줄로 뭉뚱그려진 로그를 쓰고 있다면 그 로그부터 고치는 게 순서다. T3이 찍혔는데 상태가 NOT SELECTED라면 타이머 문제가 아니라 상태 기계 버그다.
만료 뒤 동작도 다르다. T6과 T7은 연결의 실패라서 TCP를 끊고, T3은 트랜잭션 하나만 실패시키고 연결은 둔다.
위 캡처의 4004 ms는 내 클라이언트가 정한 대기값이지 T6나 T7의 값이 아니다. 기본값은 출발점일 뿐 합의가 아니니, 실제 값은 인터페이스 사양서에서 확인하고 쓰는 게 맞다.
헬스체크를 고치는 쪽이 빠르다
감시 대상을 "Linktest가 돌아오는가"에서 **"HSMS 상태가 SELECTED인가"**로 바꾸면 첫 번째 형태는 바로 드러난다. 대부분의 호스트 스택은 세션 상태를 API로 노출한다. 그걸 안 쓰고 keepalive 왕복만 보는 감시가 아직도 흔하다.
S1F13을 던져보는 것으로 대신하려는 유혹이 있는데, 위 캡처가 그게 왜 안 되는지 보여준다. NOT SELECTED에서도 답이 올 수 있다. SELECTED까지 갔는데 S1F13에만 답이 없는 반대 경우는 SELECTED인데 COMMUNICATING이 아닌 상태 쪽이다.
두 번째 형태는 반대로 keepalive 감시로 잡히지만, 끊긴 뒤에 잡힌다. 재연결 루프가 T5(Connect Separation Timeout, 기본 10초)를 지키는지도 같이 봐두는 게 좋다. 안 지키면 장비가 회복하기 전에 연결 시도가 몰린다.
직접 재현해보기
두 번째 캡처는 장비에 silentAfterSelect를 걸어서 만들었고, 캡처 직후 {}로 되돌린 다음 정상 Select 왕복으로 복구를 확인했다. 첫 번째 캡처는 폴트 없이 순서만 바꾼 것이다 — Select.req를 보내기 전에 Linktest.req와 S1F13을 먼저 던지면 그대로 재현된다.
자기 호스트 스택을 붙여놓고 감시 화면이 각각을 뭐라고 표시하는지 보면 — 초록불이 뜨는지 — 헬스체크를 고쳐야 할지 1분 안에 안다. Select가 아예 안 끝나는 쪽이라면 응답 없는 Select의 세 가지 원인에서 나머지 둘도 같이 볼 수 있다. 위 바이트는 전부 SECS/GEM 시뮬레이터의 passive listener에 소켓 하나 붙이면 그대로 나온다.