← 전체 글
SECS/GEM/약 15분 읽기/ 조회

Linktest에 답이 없는데 S1F2는 0.3 ms에 온다 — 그래도 소켓을 끊는 게 맞다

씹힌 Linktest.req로 T6가 만료돼도 같은 소켓의 S1F1은 0.3 ms 만에 S1F2로 답했지만, 장비가 SELECTED여도 host는 E37대로 끊는 게 맞다.

SECS/GEMMES문제 해결

Host 쪽 로그에는 linktest timeout이 찍혔다. 그런데 운영 화면의 장비 아이콘은 초록색 그대로다. 테스트로 S1F1을 하나 던져 보면 S1F2가 밀리초 단위로 돌아온다.

여기서 대부분의 host 구현이 흔들린다. 타이머는 터졌는데 data path는 멀쩡하다. 그러면 세션을 살릴 것인가, 규격대로 끊을 것인가.

2026-09-18에 잡은 캡처에서는 후자가 맞다. 씹힌 Linktest.req 뒤 6초 지나서 보낸 S1F1이 0.3 ms 만에 S1F2로 답했고, 그 시각 장비 쪽 상태는 hsmsState SELECTED, commState COMMUNICATING, socketConnected true였다. 문제를 아는 쪽은 host 하나뿐이었다.

Linktest.req는 장비에 도착하고도 Linktest.rsp가 오지 않아 host의 T6가 흐르는 동안, 같은 소켓의 S1F1이 0.3 ms 만에 S1F2로 답하고, 장비는 SELECTED와 COMMUNICATING을 유지한 채 host가 TCP를 닫는다 HostEQ1 T6 만료 Select.req Select.rsp S1F13 S1F14 Linktest.req #1 S1F1 Linktest.req #2 TCP 종료 Linktest.rsp 없음 — ignoreLinktest SELECTED · COMMUNICATING Linktest.rsp 없음 S1F2 — 0.3 ms

아래 hex는 전부 실제로 소켓에 오간 것이다. SECS/GEM 시뮬레이터의 EQ1을 HSMS passive listener(127.0.0.1:5501, SessionID 11 = 0x000B)로 띄우고, 바깥에서 Python 소켓 클라이언트로 두드렸다. run마다 TCP 연결을 새로 맺었고, host가 보낸 모든 메시지는 장비 쪽 패킷 로그에 RX로 남아 있다. 시각은 클라이언트에서 잰 값이고, 원본 export는 content/demos/hsms-linktest-timeout-data-path-still-alive.json에 넣어 뒀다. 캡처 날짜는 2026-09-18.

E37은 HSMS header 바이트를 0부터 센다. bytes 0–1 SessionID, byte 2, byte 3, byte 4 PType, byte 5 SType, bytes 6–9 SystemBytes. 그 앞에 4바이트 길이 prefix가 붙는다.

run B — Linktest만 골라서 버리게 해 놓고 잡았다

ignoreLinktest fault 하나만 켰다. 연결은 00:05:10.523에 맺었다.

Select은 정상이다.

00:05:10.523 TX Select.req   00 00 00 0A 00 0B 00 00 00 01 00 00 00 B1
             RX Select.rsp   00 00 00 0A 00 0B 00 00 00 02 00 00 00 B1   1.6 ms
00 00 00 0A   Length = 10 (control message, body 없음)
00 0B         bytes 0-1  SessionID = 11
00            byte 2     0
00            byte 3     Select.rsp의 Select Status. 이 캡처에서는 0
00            byte 4     PType = 0
01 / 02       byte 5     SType 1 Select.req / 2 Select.rsp
00 00 00 B1   bytes 6-9  SystemBytes 그대로 echo

세션은 SELECTED다. 이어서 S1F13을 보낸다. SELECTED 다음에 통신 확립을 따로 해야 하는 이유는 SELECTED는 통신 중이라는 뜻이 아니다 쪽에 적어 뒀다.

00:05:10.525 TX S1F13  00 00 00 1C 00 0B 81 0D 00 00 00 00 00 B2
                       01 02 41 07 48 4F 53 54 43 41 50 41 05 31 2E 30 2E 30
             RX S1F14  00 00 00 37 00 0B 01 0E 00 00 00 00 00 B2
                       01 02 21 01 00 01 02 41 15 ... 41 0D ...   0.9 ms
S1F13  byte 2 = 0x81  W-bit 1, Stream 1
       byte 3 = 0x0D  Function 13
       body   L[2]{ A[7] 'HOSTCAP', A[5] '1.0.0' }
S1F14  Length 55, byte 2 = 0x01 Stream 1, byte 3 = 0x0E Function 14
       body   L[2]{ B[1] 00 (COMMACK 0), L[2]{ A[21] MDLN, A[13] SOFTREV } }

S1F13/S1F14가 끝났으니 commState는 COMMUNICATING이다. 여기까지는 어느 라인에서든 매일 아침 일어나는 일이다.

이제 Linktest.req 차례다.

00:05:10.526 TX Linktest.req  00 00 00 0A 00 0B 00 00 00 05 00 00 00 B3
             RX               (없음 — 6,006.1 ms 기다린 뒤 클라이언트가 넘어감)
00 00 00 0A   Length 10
00 0B         bytes 0-1  SessionID 11
00 / 00       byte 2, byte 3  control message라 둘 다 0
00            byte 4     PType 0
05            byte 5     SType 5 = Linktest.req
00 00 00 B3   bytes 6-9  SystemBytes 0x000000B3

6,006.1 ms는 내 클라이언트가 참은 시간이지 어떤 T 타이머의 값도 아니다. E37 기본값이면 T6(5 s)는 그 안에 이미 지났다.

그리고 프레임이 중간에서 죽은 게 아니다. 장비 쪽 로그가 받았다고 말한다.

2026-09-18T00:05:10.527Z RX     SType 5           00 00 00 0A 00 0B 00 00 00 05 00 00 00 B3
2026-09-18T00:05:10.528Z FAULT  Linktest.req ignored (ignoreLinktest)

1 ms 만에 RX가 찍히고, 그다음 줄에서 버렸다. host가 보는 것은 침묵뿐이다.

같은 소켓에서 S1F2는 0.3 ms에 온다

Linktest를 포기한 클라이언트가 연결을 그대로 둔 채 S1F1을 던졌다.

00:05:16.538 TX S1F1  00 00 00 0A 00 0B 81 01 00 00 00 00 00 B4
             RX S1F2  00 00 00 32 00 0B 01 02 00 00 00 00 00 B4
                      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   0.3 ms
S1F1  byte 2 = 0x81  W-bit 1, Stream 1
      byte 3 = 0x01  Function 1
      body 없음
S1F2  Length 50, byte 2 = 0x01, byte 3 = 0x02
      01 02        L[2]
      41 15 ...    A[21] 'VX-9000 Plasma Etcher'   MDLN
      41 0D ...    A[13] 'SECSGEM-1.4.1'           SOFTREV

0.3 ms. 같은 TCP 연결, 같은 SessionID, SystemBytes 0x000000B4까지 정확히 짝이 맞는 완전한 S1F2다. control transaction은 통째로 사라지는데 data transaction은 왕복 0.3 ms에 끝난다.

두 번째 Linktest도 결과가 같다.

00:05:16.538 TX Linktest.req #2  00 00 00 0A 00 0B 00 00 00 05 00 00 00 B5
             RX               (없음 — 6,001.4 ms)
2026-09-18T00:05:16.539Z FAULT  Linktest.req ignored (ignoreLinktest)
2026-09-18T00:05:16.539Z RX     SType 5           00 00 00 0A 00 0B 00 00 00 05 00 00 00 B5

TCP 종료는 host가 00:05:22.545에 먼저 했다. 장비는 끝까지 닫지 않았고 FIN도 보내지 않았다. 참고로 이 시뮬레이터는 Linktest를 먼저 거는 쪽이 아니다. 그래서 장비 자신의 T6나 linktest 주기가 여기서 무언가를 했을지는 확인하지 못했다.

장비 눈에는 아무 일도 없었다

Linktest #1이 씹힌 직후와 host가 닫기 직전에 GET /api/state를 읽었다. 두 번 다 같았다.

hsmsState        SELECTED
commState        COMMUNICATING
controlState     OFF-LINE
socketConnected  true

클라이언트가 Linktest 두 번을 기다린 12초 동안 장비는 정상이라고 믿고 있었다. 이 비대칭이 핵심이다. 장비 업체에 전화하면 "저희 쪽은 연결 정상입니다"라는 답이 돌아오고, 그 말은 거짓말이 아니다.

host가 닫은 뒤에 다시 읽으면 이렇게 바뀐다.

hsmsState        NOT CONNECTED
commState        NOT COMMUNICATING
socketConnected  false

commState가 스스로 NOT COMMUNICATING으로 내려간다. 소켓이 죽으면 E30 통신 확립도 같이 죽는다는 뜻이고, 이건 재접속 코드를 짤 때 그대로 쓰이는 사실이다.

그래서 끊어야 하나

끊어야 한다.

E37(HSMS-SS)은 T6를 Control Transaction Timeout으로 정의한다. 기본 5 s, 범위 1–240 s다. Linktest.req는 control transaction이고, control transaction에서 T6가 만료되면 통신 실패로 보고 TCP 연결을 닫는다. 타이머별 만료 동작과 기본값은 어느 HSMS 타이머가 터진 것인가에 정리해 뒀으니 여기서 다시 쓰지 않는다.

여기서 host 구현자가 빠지기 쉬운 유혹은 이것이다. "data는 흐르는데 왜 끊나. Linktest 실패를 경고로 낮추고 세션을 살리자."

그러면 E37에 없는 상태를 하나 만들게 된다. SELECTED이지만 control transaction은 못 믿는 상태. 그 상태에서 다음 판단을 무슨 근거로 내릴 건가. Linktest가 안 오는 peer는 Separate.req도 안 보낼 수 있고, S9 계열 에러 메시지도 안 보낼 수 있다. control plane 전체가 확인 불가라는 뜻이지 Linktest 하나만 고장 났다는 뜻이 아니다. 한 번 성공한 S1F1은 다음 S1F1을 보장하지 않는다.

끊는 쪽의 비용은 명확하다. 재접속 한 번, S1F13 한 번, 수백 밀리초. 살리는 쪽의 비용은 명확하지 않다 — 며칠 뒤에 아무도 재현 못 하는 상태 불일치로 돌아온다. 명확한 쪽을 고른다.

끊고 나면 전부 처음부터다

fault를 {}로 되돌리고 새 연결을 맺었다(00:05:23.064). 순서가 그대로 반복된다.

TX Select.req    00 00 00 0A 00 0B 00 00 00 01 00 00 00 C1
RX Select.rsp    00 00 00 0A 00 0B 00 00 00 02 00 00 00 C1   3.0 ms  (Select Status 0)
TX Linktest.req  00 00 00 0A 00 0B 00 00 00 05 00 00 00 C2
RX Linktest.rsp  00 00 00 0A 00 0B 00 00 00 06 00 00 00 C2   0.4 ms

Linktest.rsp가 0.4 ms에 돌아온 것이 fault가 실제로 꺼졌다는 증거다.

재접속 경로에서 빠뜨리기 쉬운 건 S1F13이다. 위에서 본 대로 소켓이 닫히면 commState는 NOT COMMUNICATING이 된다. Select.rsp만 받고 곧장 데이터 폴링으로 넘어가면 이렇게 된다 — baseline run에서 S1F13 전에 S1F1을 보내 본 결과다.

TX S1F1  00 00 00 0A 00 0B 81 01 00 00 00 00 00 A3
RX S1F0  00 00 00 0A 00 0B 01 00 00 00 00 00 00 A3   0.1 ms  (header만, body 없음)

byte 2가 0x01(W-bit 0, Stream 1), byte 3이 0x00, 즉 Function 0. SystemBytes는 0x000000A3 그대로다. 0.1 ms 만에 온 abort다. 이 E30 gate의 전체 동작은 S1F13 전에는 아무것도 안 받는다 쪽에 있다. 모든 run이 S1F13을 먼저 보내는 이유가 이것이다.

내 host에서 확인할 것

장비가 없어도 코드만 보면 되는 것들이다.

  1. 타이머 로그가 T6라고 말하는가, linktest timeout·keepalive failed 같은 자체 문구만 남기는가. 어느 SEMI 타이머가 터진 것인지 로그에 없으면 vendor와의 대화가 시작되지 않는다.
  2. T6 만료에서 TCP 소켓을 닫는가, Linktest를 무한 재시도하는가. 닫지 않고 Linktest를 무한 재시도하는지 자기 stack에서 확인해 볼 값이다. E37이 요구하는 건 연결을 내리는 쪽이다.
  3. 재접속 경로가 S1F13을 다시 보내는가. Select.rsp만 확인하고 폴링을 재개하면 S1F0이 돌아온다. COMMACK까지 받은 뒤에 데이터 큐를 푼다.
  4. T6가 도는 동안 도착한 data message를 어떻게 처리하는가. 그 S1F2를 근거로 타이머를 취소하는 코드가 있다면 지운다. 반대로 그 프레임을 조용히 버리기만 해도 안 된다 — 최소한 로그 한 줄은 남겨야 나중에 이 글의 상황을 재구성할 수 있다.
  5. 닫을 때 Separate.req를 먼저 보내는가. 보내도 되지만 응답을 기다리지는 않는다. 응답을 못 주는 peer라서 닫는 중이다.

같은 실험은 scadathings.com/secs-gem-simulator에서 그대로 할 수 있다. passive listener를 띄우고 ignoreLinktest 하나만 켠 다음, 자기 host stack을 붙여 T6 만료 후에 무엇을 하는지 보면 된다. 위 hex와 장비 로그는 전부 거기서 나왔고, 원본은 content/demos/hsms-linktest-timeout-data-path-still-alive.json에 있다.