개발할 때는 멀쩡하다가 장비 여러 대를 붙이면 가끔 응답을 놓치는 호스트가 있다. 로그에는 T3 타임아웃이 찍히는데 tcpdump에는 응답이 분명히 들어와 있다. 이 조합이면 십중팔구 파서 문제다. recv() 한 번이 메시지 하나라고 가정한 코드다.
TCP는 바이트 스트림이다. 경계를 보존하지 않는다. HSMS에서 메시지 경계를 정하는 건 SEMI E37이 모든 메시지 앞에 붙이는 4바이트 길이 필드 하나뿐이고, 그걸 세는 일은 TCP가 아니라 애플리케이션 몫이다. 아래는 시뮬레이터의 passive 리스너에 소켓을 직접 열어 뽑은 캡처 네 건이다. write 한 번에 메시지 두 개, 메시지 하나를 write 두 번, 길이 필드가 부른 것보다 바디가 모자란 경우, 그리고 길이 접두어가 1 MiB를 부르는 경우.
필드부터 정리하고 간다. E37의 길이 필드는 4바이트 빅엔디안이고, 10바이트 헤더와 그 뒤 메시지 텍스트를 합해서 센다. 자기 자신 4바이트는 세지 않는다. 헤더는 바이트 0-1이 SessionID, 바이트 2가 W-bit(비트 7)과 Stream, 바이트 3이 Function, 바이트 4가 PType(0이면 SECS-II), 바이트 5가 SType(0 데이터, 1 Select.req, 2 Select.rsp, 5 Linktest.req, 6 Linktest.rsp, 9 Separate.req), 바이트 6-9가 SystemBytes다. 제어 메시지는 바디가 없으니 길이는 언제나 10이다.
write 한 번, 메시지 두 개
호스트 역할로 소켓을 열고 Select.req와 Linktest.req를 이어 붙여 sendall() 한 번으로 던졌다.
TX (한 번의 write, 28 bytes)
00 00 00 0A 00 0B 00 00 00 01 00 00 11 01
00 00 00 0A 00 0B 00 00 00 05 00 00 11 02
장비 쪽 로그에는 이게 RX 두 건으로 남았다. 06:04:10.654 SType 1(Select.req) SystemBytes 0x1101, 06:04:10.655 SType 5(Linktest.req) SystemBytes 0x1102. 둘 다 길이 필드는 10. 커널이 하나의 세그먼트로 붙여 보냈는데 수신 측은 메시지 두 개로 셌다. 붙여 보내는 쪽이 정상이고, 나눠 세는 쪽도 정상이다.
돌아오는 방향은 읽는 타이밍에 따라 달라진다. 보내자마자 recv()를 부르면 두 번에 나눠 들어온다.
recv#1 (14 bytes) 00 00 00 0A 00 0B 00 00 00 02 00 00 11 01 Select.rsp, SystemBytes 0x1101
recv#2 (14 bytes) 00 00 00 0A 00 0B 00 00 00 06 00 00 11 02 Linktest.rsp, SystemBytes 0x1102
같은 28바이트를 똑같이 던져 놓고 300ms 쉰 다음 recv(4096)을 한 번만 부르면 이렇게 들어온다.
recv#1 (28 bytes)
00 00 00 0A 00 0B 00 00 00 02 00 00 12 01 Select.rsp, SystemBytes 0x1201
00 00 00 0A 00 0B 00 00 00 06 00 00 12 02 Linktest.rsp, SystemBytes 0x1202
같은 상대, 같은 바이트, 같은 순서. 다른 건 내가 언제 읽었느냐뿐이다. 앞의 14바이트만 파싱하고 나머지를 버리는 코드는 두 번째 실행에서 Linktest.rsp를 영영 잃는다. 그리고 증상은 "가끔 응답을 놓친다"로 나타난다. 한산할 때는 첫 번째 모양으로 오니까 재현이 안 된다.
이건 루프백에서 관측한 결과다. 실제 망을 타면 또 다르게 나뉜다. 다르게 나뉜다는 것 자체가 요점이다.
메시지 하나, write 두 번
반대 방향도 확인했다. Select.req 14바이트를 6바이트와 8바이트로 쪼개서 보냈다.
TX 청크 1 00 00 00 0A 00 0B
RX (800ms 동안 아무것도 안 옴)
TX 청크 2 00 00 00 01 00 00 13 01
RX 00 00 00 0A 00 0B 00 00 00 02 00 00 13 01 Select.rsp, SystemBytes 0x1301
첫 청크에 길이 접두어는 통째로 들어 있다. 길이 10짜리가 온다는 것까지는 수신 측이 이미 안다. 그래도 아무 반응이 없다. 장비 로그를 봐도 TCP 연결이 06:04:12.458에 잡힌 뒤 RX는 06:04:13.259, 즉 나머지 8바이트가 도착한 시점에 딱 한 건만 찍혔다. 14바이트가 다 모이기 전까지 그건 메시지가 아니다.
길이만큼 안 오면 — T8을 아무도 안 걸어줄 때
앞의 캡처는 전부 제어 메시지라 바디가 없다. 바디가 있는 쪽을 보려고 SELECTED까지 간 뒤 S1F13으로 통신을 열고, S1F3을 일부러 4바이트 모자라게 보냈다.
TX 00 00 00 0C 00 0B 81 0D 00 00 00 00 14 02 01 00 S1F13 W, 길이 12, 바디 L[0]
RX 00 00 00 37 00 0B 01 0E 00 00 00 00 14 02 ... S1F14, MDLN "VX-9000 Plasma Etcher", SOFTREV "SECSGEM-1.4.1"
S1F3 전체 프레임은 22바이트다. 길이 필드는 18 — 헤더 10 + 바디 8.
전체 00 00 00 12 00 0B 81 03 00 00 00 00 14 03 01 01 B1 04 00 00 00 01
TX 00 00 00 12 00 0B 81 03 00 00 00 00 14 03 01 01 B1 04 (22바이트 중 18바이트만)
RX (7.0초 동안 아무것도 안 옴)
TX 00 00 00 01 (남은 4바이트)
RX 00 00 00 31 00 0B 01 04 00 00 00 00 14 03 ... S1F4
바디 바이트는 SEMI E5의 아이템 헤더다. 01 01이 원소 하나짜리 L, B1 04가 길이 바이트 1개를 쓰는 U4에 4바이트, 그리고 값 00 00 00 01. 내가 자른 지점은 그 U4 값 한가운데다.
장비 쪽 로그에서 06:04:13.763(S1F13 RX)과 06:04:20.768(S1F3 RX) 사이에는 RX가 하나도 없다. 7초를 끊긴 채로 버티다가 나머지 4바이트가 붙자 그제서야 길이 18짜리 메시지 한 건으로 찍히고 S1F4가 나갔다. 여기서 오는 S1F4는 L[4]인데 내가 요청한 SVID는 하나였다 — 시뮬레이터가 SVID 목록을 무시하고 전부 돌려주는 것이고, 프레이밍과는 상관없는 동작이다.
중요한 건 7초를 기다려 줬다는 사실이다. E37의 T8(network intercharacter timeout)은 메시지 하나를 받는 도중 바이트 사이 간격이 이 값을 넘으면 그 메시지를 실패로 보고 연결을 끊게 되어 있고, 기본값은 5초다. 5초를 넘겼는데 이 리스너는 아무 일도 하지 않았다. 이 시뮬레이터는 T8을 구현하지 않는다 — T3·T6·T7·T8을 캡처로 가르는 글에서 20초짜리 프레임으로 같은 결과를 다시 확인했다.
시뮬레이터 하나의 동작이지 실제 장비 전체에 대한 주장은 아니다. 호스트 입장에서 결론은 어차피 하나다. T8은 상대가 걸어 주는 타이머가 아니라 내가 거는 타이머다. 반쯤 온 메시지를 상한 없이 버퍼에 들고 있으면, 상대가 조용히 죽었을 때 그 연결은 영원히 "곧 다 올 것 같은" 상태로 남는다.
길이 접두어가 1 MiB를 부를 때
마지막으로 길이 필드에 거짓말을 시켰다. 헤더 10바이트만 붙여 놓고 길이는 0x00100000, 1,048,576바이트라고 선언했다.
TX 00 10 00 00 00 0B 00 00 00 01 00 00 15 01
RX (없음)
그 다음 write → BrokenPipeError [Errno 32]
장비 로그에 RX는 찍히지 않았다. 한 건도 없다. 리스너는 길이 값을 읽자마자 상한을 넘은 걸 보고 소켓을 파괴했고, 그래서 내 다음 write가 EPIPE로 깨졌다. 이 리스너의 상한은 64 KiB다.
4바이트 길이 필드는 4,294,967,295까지 표현한다. 인코딩상 1 MiB는 완벽하게 합법적인 값이다. 그 값을 그대로 믿고 버퍼를 잡을지, 상한을 걸고 연결을 끊을지는 E37이 정해 주지 않고 구현이 정한다. 끊는 쪽이 맞다. 스트림이 한 번 어긋나면 그 자리의 4바이트는 이미 길이가 아니라 그냥 남의 데이터고, 거기 맞춰 메모리를 잡아 주는 파서는 잘못된 한 바이트에 프로세스를 내준다.
파서가 지켜야 할 것
- 소켓에서 읽은 걸 누적 버퍼에 붙인다. 읽기 한 번을 메시지 하나로 취급하지 않는다.
- 버퍼가 4바이트 이상이면 길이를 읽는다. 빅엔디안이다.
4 + 길이바이트가 다 모였을 때만 한 개를 잘라낸다.- 잘라내고 루프를 다시 돈다. 한 번의 읽기에 완성된 메시지가 몇 개든 들어 있다.
if가 아니라while이다. - 다 안 모였으면 그대로 두고 다음 읽기를 기다리되, 그 대기에 T8 상한을 건다. 기본 5초.
- 길이 값을 그대로 믿고 버퍼를 할당하지 않는다. 상한은 그 연결에서 나올 수 있는 가장 큰 SECS-II 메시지 기준으로 잡고, 넘으면 파싱을 이어가지 말고 끊는다.
상용 스택을 쓴다면 이 루프는 이미 들어 있다. 그래도 알아 둘 값어치는 있다. 캡처를 보면서 "왜 여기 메시지가 두 개지"를 이해하는 데 필요하고, 무엇보다 T3 타임아웃 로그를 봤을 때 회선을 의심할지 파서를 의심할지 가르기 때문이다. 응답이 와이어에 찍혀 있는데 T3가 터졌다면 회선은 죄가 없다.
위 캡처는 전부 SECS/GEM 시뮬레이터의 passive 리스너를 상대로 소켓을 열어 만들었다. TCP 연결 다섯 번이고, SystemBytes 0x11xx부터 0x15xx까지가 순서대로 그 다섯 번이다. 아이템 헤더 바이트를 더 파고들 거라면 SECS-II 아이템 헤더와 길이 바이트 쪽이 이어진다.