호스트 로그에 timeout 한 줄만 찍혀 있으면 그다음 30분은 대체로 추측이다. HSMS 타이머는 다섯 개고 감시하는 구간이 전부 다르다. 어느 타이머가 터졌는지가 곧 어디를 봐야 하는지다. T6가 터졌으면 세션이 아직 안 열린 것이고, T3가 터졌으면 세션은 열렸는데 애플리케이션이 답을 안 준 것이다. 두 경우에 확인할 것이 하나도 겹치지 않는다.
다섯 개가 재는 구간은 서로 겹치지 않는다
SEMI E37(HSMS-SS)이 정의하는 타이머는 다섯 개다. 기본값과 범위는 E37의 파라미터 표에 실려 있는 값이다.
| 타이머 | E37 이름 | 기본값 (범위) | 재는 구간 | 터졌을 때 |
|---|---|---|---|---|
| T3 | Reply Timeout | 45초 (1–120) | W-bit가 선 데이터 메시지를 보낸 뒤 응답이 올 때까지 | 세션은 살아 있고, 애플리케이션이 답을 못 만들었다 |
| T5 | Connect Separation Timeout | 10초 (1–240) | 연결 시도와 다음 연결 시도 사이 | 오류가 아니다. 재접속을 이 간격 안에 다시 시도하지 말라는 뜻이다 |
| T6 | Control Transaction Timeout | 5초 (1–240) | 컨트롤 트랜잭션(Select, Deselect, Linktest)이 열려 있는 시간 | 세션이 아직 안 열렸거나, 열린 줄 알았는데 아니다 |
| T7 | NOT SELECTED Timeout | 10초 (1–240) | TCP는 붙었는데 NOT SELECTED로 머무는 시간 | 상대가 Select.req를 보내지 않는다 |
| T8 | Network Intercharacter Timeout | 5초 (1–120) | 메시지 한 개를 이루는 바이트 사이의 간격 | 프레임이 절반만 도착하고 멎었다 |
기본값은 출발점이지 합의된 값이 아니다. 실제 값은 호스트와 장비가 인터페이스 스펙에서 정하고, 45초짜리 T3를 그대로 쓰는 라인은 드물다. 응답 없는 S2F41 하나에 45초를 서 있어 보면 왜 다들 10초 근처로 당기는지 알게 된다.
누가 켜는 타이머인지가 절반이다. T3와 T6는 요청을 보낸 쪽이 켠다 — 데이터 메시지면 T3, 컨트롤 메시지면 T6. T5는 active(먼저 붙는 쪽) 전용이다. T7은 TCP가 붙은 뒤 SELECTED가 되기를 기다리는 쪽, 실무에서는 Select.req를 기다리는 passive 장비 쪽이다. T8은 메시지를 절반만 받아 든 쪽이면 어느 쪽이든 켠다.
만료 뒤 동작도 E37에서 서로 다르다. T6·T7·T8 만료는 연결 자체의 실패로 보고 TCP 연결을 끊는다. T3 만료는 그 트랜잭션 하나만 실패로 처리하고 연결은 그대로 둔다. 그리고 T3가 터졌을 때 상대에게 S9F9(Transaction Timer Timeout)를 보내는 규칙은 E37이 아니라 SECS-II(SEMI E5) 쪽 이야기다. HSMS 문서에서 S9F9를 찾다 시간 버리지 말 것.
혼동이 자주 나는 지점이 하나 더 있다. T1, T2, T4는 HSMS 타이머가 아니라 SECS-I(SEMI E4)의 시리얼 타이머다. E4는 T1을 문자 간, T2를 프로토콜, T4를 블록 간 타임아웃으로 쓴다. HSMS는 이 중 T3만 이름 그대로 물려받고 T5부터 T8까지를 새로 얹었다. 그러니 이더넷으로만 붙는 장비의 인터페이스 스펙에 T1이 적혀 있으면 그 문서는 시리얼 시절에서 복사된 것이다. 값을 맞추기 전에 그 항목이 왜 거기 있는지부터 물어보는 게 맞다.
캡처: 정상 왕복부터
아래는 2026-09-10에 시뮬레이터의 passive 리스너(127.0.0.1:5501)에 파이썬 소켓으로 붙어 직접 뜬 것이다. hex는 장비 쪽이 RX/TX로 기록한 실제 바이트고, 시간은 호스트 쪽에서 잰 값이다. 세션 ID는 00 0B(11). 원본 export는 content/demos/hsms-timeout-timers-t3-t5-t6-t7-t8.json에 그대로 넣어뒀다.
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 3.6 ms
앞 4바이트는 길이 프리픽스(0A = 10, 헤더만)이고 이어지는 10바이트가 헤더다. 바이트 0–1이 세션 ID, 컨트롤 메시지라 바이트 2–3은 0, 바이트 4가 PType(0 = SECS-II), 바이트 5가 SType(01 Select.req → 02 Select.rsp), 바이트 6–9가 SystemBytes 00 00 03 01. 요청 값을 그대로 돌려줬다.
TX S1F13 W=1 00 00 00 1A 00 0B 81 0D 00 00 00 00 03 02
01 02 41 06 48 4F 53 54 49 44 41 04 31 2E 30 30
RX S1F14 00 00 00 37 00 0B 01 0E 00 00 00 00 03 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 2.4 ms
데이터 메시지라 바이트 2가 81 — 최상위 비트가 W-bit, 나머지 7비트가 Stream 1이고, 바이트 3 0D가 Function 13이다. 응답의 바이트 2는 01이니 W-bit가 내려갔고, SystemBytes 00 00 03 02는 그대로다. 본문은 01 02(L[2]) 아래 21 01 00 = COMMACK 0, 그 아래 A[21] VX-9000 Plasma Etcher와 A[13] SECSGEM-1.4.1. 2.4 ms에 왔으니 T3가 셀 틈이 없었다.
TX Linktest.req 00 00 00 0A 00 0B 00 00 00 05 00 00 03 03
RX Linktest.rsp 00 00 00 0A 00 0B 00 00 00 06 00 00 03 03 1.2 ms
Linktest는 SType 5 / 6이고 본문이 없다. 살아 있는 링크의 왕복이 1.2 ms인 회선에서 T6 기본값 5초가 터졌다면 그건 지연이 아니라 상대가 죽은 것이다.
T6는 이렇게 터진다
리스너에 ignoreLinktest 폴트를 걸었다. Linktest.req는 장비까지 도착하고(RX 로그에 남는다) 응답만 삼켜진다.
06:02:51.362 RX Select.req 00 00 00 0A 00 0B 00 00 00 01 00 00 04 01
06:02:51.362 TX Select.rsp 00 00 00 0A 00 0B 00 00 00 02 00 00 04 01 Select Status 0
06:02:51.364 RX Linktest.req 00 00 00 0A 00 0B 00 00 00 05 00 00 04 02
06:02:51.364 FAULT Linktest.req ignored (ignoreLinktest)
(응답 없음)
06:02:56.37 호스트가 T6 = 5초에서 포기하고 연결을 끊음 — 실측 5.005초
포인트는 장비 로그에 RX가 남았다는 것이다. 패킷은 도착했고, 세션도 SELECTED다. 그래도 호스트에서는 "링크가 죽었다"로 보인다. Linktest는 컨트롤 트랜잭션이므로 여기서 터지는 건 T3가 아니라 T6다. 로그가 둘을 구분하지 않으면 이 둘을 같은 줄로 읽게 된다.
silentAfterSelect로 Select.req 자체를 삼키게 하면 같은 5초가 세션이 열리기도 전에 흐른다.
06:02:56.381 RX Select.req 00 00 00 0A 00 0B 00 00 00 01 00 00 05 01
06:02:56.381 FAULT Select.req ignored (silentAfterSelect)
(응답 없음)
06:03:01.39 호스트가 포기 — 실측 5.005초
TCP는 붙었는데 Select.rsp가 없는 상태, 즉 NOT SELECTED다. 여기서 T3를 찍는 호스트가 있다면 그건 타이머 문제가 아니라 상태 기계 버그다. 두 캡처 모두 뜨자마자 폴트를 {}로 되돌렸다.
T3 만료는 이 리스너로는 못 만든다. 데이터 메시지에는 항상 답을 하고, 답할 게 없으면 SxF0(Function 0, 트랜잭션 abort)라도 돌려준다. 그런데 이건 E5에서 엄연한 응답이라 받는 순간 T3는 멈춘다 — 실제 현장에서도 무응답과 SxF0는 전혀 다른 사건이다. 앞의 S1F13 왕복까지가 이 캡처가 T3에 대해 증명하는 전부다.
상대가 지켜준다고 믿으면 안 되는 두 개
T7과 T8은 직접 시험했고, 둘 다 결과가 같았다.
TCP만 붙이고 Select.req를 한 번도 보내지 않은 채 30초를 버텼다. FIN은 오지 않았고, 30초 뒤에 보낸 Select.req는 정상적으로 받아들여졌다.
06:03:01.391 TCP 연결됨 (127.0.0.1)
... 30.0초 동안 아무것도 보내지 않음, FIN도 오지 않음 ...
06:03:31.417 RX Select.req 00 00 00 0A 00 0B 00 00 00 01 00 00 06 01
06:03:31.417 TX Select.rsp 00 00 00 0A 00 0B 00 00 00 02 00 00 06 01
T7 기본값의 세 배를 놀고 있어도 연결이 살아 있다. 이 리스너는 T7을 강제하지 않는다.
두 번째는 프레임을 반으로 쪼갠 것이다. 14바이트 Select.req 중 앞 6바이트만 보내고 20초를 쉰 뒤 나머지 8바이트를 보냈다.
06:03:31.418 TCP 연결됨
TX (앞 6바이트) 00 00 00 0A 00 0B
... 20.0초 정지 ...
TX (나머지 8) 00 00 00 01 00 00 07 01
06:03:51.418 RX Select.req 00 00 00 0A 00 0B 00 00 00 01 00 00 07 01
06:03:51.418 TX Select.rsp 00 00 00 0A 00 0B 00 00 00 02 00 00 07 01
한 프레임의 첫 바이트와 마지막 바이트 사이가 20초, T8 기본값의 네 배다. 그래도 받아들이고 Select.rsp까지 돌려줬다. HSMS 길이 헤더와 TCP 프레이밍 글에서 이 시뮬레이터가 T8을 지키는지 확인하지 못했다고 적어뒀는데, 이번 캡처로 답이 나왔다. 지키지 않는다.
시뮬레이터 한 대의 성질이지 실제 장비가 이렇다는 뜻은 아니다. 그런데 호스트 입장에서 결론은 어느 쪽이든 같다. T7과 T8은 내가 켜는 것이지 상대가 대신 켜주는 것이 아니다. 상대가 T7을 안 지키면 죽은 소켓이 NOT SELECTED로 영원히 남고, T8을 안 지키면 절반짜리 메시지가 파서 버퍼에 영원히 남는다. 둘 다 재고할 필요 없이 그냥 호스트에 넣어야 하는 코드다. 파서 쪽 처리는 앞의 프레이밍 글에 적어뒀다.
로그 한 줄이 사건을 결정한다
timeout 일곱 글자만 찍는 로그는 위의 표를 무용지물로 만든다. 최소한 이 네 가지는 같은 줄에 있어야 한다.
- 터진 타이머 이름 —
T3인지T6인지 - 기다리던 SystemBytes — 캡처에서 요청을 찾는 유일한 열쇠다
- SxFy 또는 SType — 데이터인지 컨트롤인지가 여기서 갈린다
- 그때의 HSMS 상태 — NOT SELECTED에서 난 T3는 타이머 문제가 아니라 순서 문제다
SystemBytes를 안 찍으면 엉뚱한 SystemBytes로 돌아온 응답과 진짜 무응답을 로그만으로는 구분할 수 없다. 캡처를 다시 떠야 한다. 커밋 한 줄이면 될 것을 재현 한 번으로 갚는 셈이다.
그리고 T6가 아니라 T3가 터졌다면 이미 세션은 열려 있다는 뜻이다. 그때부터는 HSMS가 아니라 GEM 쪽 이야기다 — SELECTED인데 S1F13에 답이 없는 경우가 그 지점이다.
타이머 값을 조정하기 전에 어떤 타이머가 터졌는지부터 로그에서 읽을 수 있어야 한다. T3 값을 45초로 늘렸더니 증상이 사라졌다면 고친 게 아니라 미룬 것이다. 위 캡처는 SECS/GEM 시뮬레이터의 passive 리스너에 소켓 하나 열면 그대로 재현된다.