호스트를 재기동할 때마다 장비가 이전 세션을 붙들고 있는 현장이 있다. 호스트 로그에는 "연결 종료"라고 찍혀 있고, 장비 화면에는 여전히 SELECTED라고 떠 있다. 둘 다 거짓말은 아니다. 호스트가 세션을 끝낸 게 아니라 소켓만 닫았기 때문이다.
HSMS에는 세션을 끝내는 전용 메시지가 따로 있다. Separate.req다. 아래 두 캡처는 그걸 쓴 경우와 안 쓴 경우를 같은 장비를 상대로 실제 소켓 위에서 잡은 것이다. 정상 종료에서는 호스트가 Select.req와 Linktest.req를 보내 각각 Select.rsp, Linktest.rsp를 받은 뒤 Separate.req를 보내고, 거기에는 응답 없음 — 곧바로 TCP 종료가 따라온다.
정상 종료는 이렇게 생겼다
호스트 역할로 소켓을 열고 세 개를 순서대로 보냈다. 장비 쪽 로그에 RX로 남은 것만 여기 옮긴다.
TX Select.req 00 00 00 0A 00 0B 00 00 00 01 00 00 02 01
RX Select.rsp 00 00 00 0A 00 0B 00 00 00 02 00 00 02 01
TX Linktest.req 00 00 00 0A 00 0B 00 00 00 05 00 00 02 02
RX Linktest.rsp 00 00 00 0A 00 0B 00 00 00 06 00 00 02 02
TX Separate.req 00 00 00 0A 00 0B 00 00 00 09 00 00 02 03
RX (없음) 장비가 TCP 연결을 닫았다
헤더는 앞의 두 세션과 똑같이 읽는다. 00 00 00 0A는 뒤에 10바이트가 온다는 뜻이고 컨트롤 메시지는 본문이 없으니 항상 10이다. 00 0B가 SessionID(11), 그다음 두 바이트는 컨트롤 메시지에서 안 쓴다. 00은 PType, 그다음 한 바이트가 SType이다. 마지막 4바이트가 SystemBytes.
SType만 보면 된다. 01이 Select.req, 02가 Select.rsp, 05가 Linktest.req, 06이 Linktest.rsp, 그리고 09가 Separate.req다. 앞의 두 쌍은 SystemBytes가 그대로 돌아왔다 — 00 00 02 01에 00 00 02 01, 00 00 02 02에 00 00 02 02. 세 번째는 아무것도 돌아오지 않았고, 대신 소켓이 EOF로 닫혔다.
응답이 없는 게 맞다
SEMI E37이 정의하는 SType 값은 데이터 메시지가 0이고, 컨트롤 메시지가 Select.req(1), Select.rsp(2), Deselect.req(3), Deselect.rsp(4), Linktest.req(5), Linktest.rsp(6), Reject.req(7), Separate.req(9)다. 번호를 훑어보면 규칙이 보인다. req에는 짝이 되는 rsp가 있는데, Reject.req와 Separate.req만 없다.
그래서 Select와 Linktest는 요청/응답 트랜잭션이고 응답이 안 오면 T6(control transaction timeout)가 걸린다. Separate.req는 트랜잭션이 아니다. 보내는 쪽은 그걸 쓰는 순간 이미 세션이 끝났다고 보고, 받는 쪽도 확인 응답 없이 NOT SELECTED로 내려간다.
여기서 실제로 사고가 나는 지점은 호스트 코드다. 종료 처리를 다른 컨트롤 메시지와 같은 헬퍼로 짜 놓으면 — 보내고, 응답 기다리고, 타임아웃 나면 예외 — 정상 종료가 매번 타임아웃 예외로 끝난다. 로그에 separate failed 같은 줄이 종료할 때마다 하나씩 쌓이고, 아무도 그게 정상 동작이라는 걸 모른다. Separate.req는 보내고 나서 소켓을 닫는 게 전부다.
캡처 뒤에 Linktest를 하나 더 밀어봤다.
TX Linktest.req 00 00 00 0A 00 0B 00 00 00 05 00 00 02 04
RX (없음)
연결이 이미 없으니 당연히 아무것도 안 온다. Separate 이후에 뭔가 더 보내고 있다면 그건 코드가 상태 전이를 안 따라간 것이다.
소켓만 닫으면 어떻게 되나
같은 장비에 다시 붙어서 Select만 하고 Separate 없이 소켓을 끊었다. SO_LINGER를 0으로 줘서 FIN 대신 RST가 나가게 했다 — 호스트 프로세스가 죽는 상황에 가깝다.
TX Select.req 00 00 00 0A 00 0B 00 00 00 01 00 00 03 01
RX Select.rsp 00 00 00 0A 00 0B 00 00 00 02 00 00 03 01
(소켓 닫음 — HSMS 메시지는 나가지 않았다)
장비 쪽 로그에는 read ECONNRESET이 찍히고 상태는 바로 NOT CONNECTED로 떨어졌다. 이 경우는 사실 괜찮다. TCP가 대신 알려줬기 때문이다.
문제는 TCP도 못 알려주는 경우다. 호스트 서버 전원이 나가거나 중간 방화벽이 세션을 조용히 버리면 FIN도 RST도 안 간다. 장비는 SELECTED인 채로 남고, 다음에 호스트가 새로 붙으면 장비 입장에서는 세션이 두 개다. 이게 "재기동했더니 안 붙는다"의 정체다. 그 상태를 여기서 캡처로 만들지는 못했다 — 루프백에서는 커널이 항상 RST를 대신 보내준다.
그래서 Linktest가 있다. 상대가 살아 있는지는 애플리케이션이 주기적으로 물어봐야만 알 수 있고, TCP의 침묵을 살아 있다는 뜻으로 읽으면 안 된다. Linktest는 통과하는데 세션은 안 서는 반대 경우는 Linktest는 받는데 Select는 끝내지 못하는 장비에서 따로 다뤘다.
끊고 바로 다시 붙지 마라
E37에는 T5(connect separation timeout)가 있다. 연결이 끊어졌거나 실패한 뒤 다음 연결 시도까지 최소한 기다려야 하는 시간이다. 이게 있는 이유는 단순하다. 재접속 루프를 아무 대기 없이 돌리면 장비의 리스너를 초당 수십 번 두드리게 되고, 그러면 진짜 문제가 뭐였는지 보이지 않는다.
호스트에서 T5를 0에 가깝게 잡아 놓은 설정을 몇 번 봤다. 개발할 때 빨리 붙으라고 줄여 놓고 그대로 나간 것이다. 값 자체는 구현마다 다르니 장비 쪽 설정과 맞추는 게 맞고, 여기서 어떤 기본값이 표준상 요구되는지는 도구로 확인하지 못했다.
호스트 쪽에서 확인할 것
- 종료 경로에서 Separate.req(SType 9)를 실제로 보내는가, 아니면 소켓만 닫는가.
- Separate.req를 보낸 뒤 응답을 기다리는가. 기다리면 안 된다.
- Separate.req를 보낸 다음 소켓을 닫는가. 안 닫으면 상대는 NOT SELECTED로 남고, 그 연결은 T7(NOT SELECTED timeout)이 정리할 몫이 된다.
- 프로세스가 비정상 종료할 때도 이 경로를 타는가. 대개 안 탄다. 그래서 장비 쪽 Linktest 주기가 마지막 방어선이다.
Separate.req 하나 안 보낸 것 때문에 다음 접속이 막히는 건, 원인과 증상 사이의 거리가 멀어서 찾는 데 오래 걸리는 종류의 문제다. 종료 코드를 먼저 보면 5분이면 끝난다.
위 캡처는 SECS/GEM 시뮬레이터의 passive 리스너를 상대로 만들었다. 직접 소켓을 열어 SType 9를 보내 보면 같은 바이트가 나온다.