호스트 폴링 루프를 빠르게 만들려고 요청을 한꺼번에 던지기 시작하면, 어느 순간 T3 타임아웃이 한 건씩 찍힌다. 그런데 장비 로그에는 응답이 다 찍혀 있다. 내가 만든 캡처에서는 요청 네 개를 보내고 응답 세 개를 받았다. 모자란 응답은 유실된 게 아니다. 애초에 오지 않을 요청이 배치 안에 하나 섞여 있었고, 응답을 도착 순서로 짝짓던 코드가 그 자리부터 한 칸씩 밀렸다. 마지막 요청은 영원히 답을 기다린다.
아래 캡처는 전부 SECS/GEM 시뮬레이터의 passive 리스너(127.0.0.1:5501)에 소켓을 직접 열어서 받았다. 시뮬레이터 자체 API가 아니라 외부 소켓이다. 그래서 장비 쪽 로그에 RX가 남아 있고, 그게 이 바이트들이 실제로 와이어를 지나갔다는 증거다. 모든 세션은 Select.req와 S1F13으로 시작해 COMMUNICATING까지 올린 상태다.
요청 네 개를 한 번에, 응답 네 개
먼저 정상 모양부터. 전부 W-bit를 세운 요청 네 개를 sendall() 한 번으로 74바이트 던졌다.
TX (write 1회, 74 bytes)
00 00 00 12 00 0B 81 03 00 00 00 00 30 10 01 01 B1 04 00 00 00 02 S1F3 W=1 SystemBytes 0x3010
00 00 00 0A 00 0B 81 01 00 00 00 00 30 11 S1F1 W=1 SystemBytes 0x3011
00 00 00 0C 00 0B 82 1D 00 00 00 00 30 12 01 00 S2F29 W=1 SystemBytes 0x3012
00 00 00 12 00 0B 81 03 00 00 00 00 30 13 01 01 B1 04 00 00 00 02 S1F3 W=1 SystemBytes 0x3013
바이트 2를 보면 81, 81, 82, 81이다. 최상위 비트가 W-bit, 남은 7비트가 Stream이다. 그래서 82는 W-bit 1 + Stream 2다. 네 요청이 동시에 열려 있는 트랜잭션 네 개가 된다.
돌아온 응답:
RX 1 00 00 00 34 00 0B 01 04 00 00 00 00 30 10 ... S1F4 SystemBytes 0x3010 (52 bytes)
RX 2 00 00 00 32 00 0B 01 02 00 00 00 00 30 11 ... S1F2 SystemBytes 0x3011 (50 bytes)
RX 3 00 00 00 8B 00 0B 02 1E 00 00 00 00 30 12 ... S2F30 SystemBytes 0x3012 (139 bytes)
RX 4 00 00 00 34 00 0B 01 04 00 00 00 00 30 13 ... S1F4 SystemBytes 0x3013 (52 bytes)
응답 쪽 바이트 2는 01, 01, 02, 01 — W-bit가 전부 내려갔다. SystemBytes 네 개는 보낸 그대로 돌아왔다. 여기까지는 아무 문제가 없고, 그래서 이 모양만 보고 코드를 짜면 함정에 걸린다.
RX 1과 RX 4를 비교해 볼 값어치가 있다. 같은 S1F3을 같은 SVID로 두 번 물었고 2 ms 차이다. 두 응답의 F8 값은 40 50 5F 5C 28 F5 C2 8F로 완전히 같다. 200 ms를 띄워 같은 걸 물었을 때는 65.0과 64.79로 달랐다. 폴링으로 받는 값은 그 순간의 샘플이고 메시지 안에 샘플 시각이 없다는 뜻이다. 그래서 같은 배치 안의 값들끼리도 "같은 시점"이라고 쓸 근거가 없다.
W-bit가 0인 요청 하나가 배치를 밀어낸다
이번에는 두 번째 요청만 W-bit를 내렸다. 나머지 셋은 그대로다.
TX (write 1회, 74 bytes)
00 00 00 12 00 0B 81 03 00 00 00 00 40 10 01 01 B1 04 00 00 00 02 S1F3 W=1 SystemBytes 0x4010
00 00 00 0A 00 0B 01 01 00 00 00 00 40 11 S1F1 W=0 SystemBytes 0x4011
00 00 00 0C 00 0B 81 0B 00 00 00 00 40 12 01 00 S1F11 W=1 SystemBytes 0x4012
00 00 00 12 00 0B 81 03 00 00 00 00 40 13 01 01 B1 04 00 00 00 02 S1F3 W=1 SystemBytes 0x4013
두 번째 줄 바이트 2가 01이다. 81에서 최상위 비트 하나만 빠졌다. 사람 눈으로 hex 덤프를 훑을 때 가장 안 보이는 차이다.
RX 1 00 00 00 34 00 0B 01 04 00 00 00 00 40 10 ... S1F4 SystemBytes 0x4010
RX 2 00 00 00 79 00 0B 01 0C 00 00 00 00 40 12 ... S1F12 SystemBytes 0x4012
RX 3 00 00 00 34 00 0B 01 04 00 00 00 00 40 13 ... S1F4 SystemBytes 0x4013
(1.5초 더 기다렸고 더 오지 않음 — 요청 4건에 응답 3건)
0x4011에는 응답 없음. 장비는 아무 잘못도 하지 않았다. SECS-II(SEMI E5)에서 W-bit 0은 "응답을 보내지 말라"는 뜻이고, 리스너는 그대로 했다. 에러도 아니고 S9도 아니다.
여기서 호스트가 두 갈래로 갈린다. 응답을 온 순서대로 내가 보낸 요청 큐에 붙이는 코드는 RX 2(0x4012)를 0x4011의 답으로 읽는다. S1F1 자리에 S1F12 바디가 들어오니 파싱은 터지거나, 운이 나쁘면 조용히 엉뚱한 값을 집는다. 그리고 마지막 0x4013에는 짝이 없으니 45초 뒤 T3가 터진다. 와이어에는 답이 찍혀 있는데 로그에는 타임아웃이 남는다.
SystemBytes로 짝짓는 코드는 아무 일도 없다. 0x4011은 애초에 트랜잭션 테이블에 넣지 않았기 때문이다 — W-bit 0으로 보냈으면 응답을 기다리는 항목이 아니다.
미구현 stream은 S7F0로 자리를 채운다
세 번째 캡처. 가운데에 이 리스너가 구현하지 않은 S7F19를 끼웠다.
TX (write 1회, 58 bytes)
00 00 00 12 00 0B 81 03 00 00 00 00 50 10 01 01 B1 04 00 00 00 02 S1F3 W=1 SystemBytes 0x5010
00 00 00 0A 00 0B 87 13 00 00 00 00 50 11 S7F19 W=1 SystemBytes 0x5011
00 00 00 12 00 0B 81 03 00 00 00 00 50 12 01 01 B1 04 00 00 00 02 S1F3 W=1 SystemBytes 0x5012
RX 1 00 00 00 34 00 0B 01 04 00 00 00 00 50 10 ... S1F4 SystemBytes 0x5010 (52 bytes)
RX 2 00 00 00 0A 00 0B 07 00 00 00 00 00 50 11 S7F0 SystemBytes 0x5011 (10 bytes)
RX 3 00 00 00 34 00 0B 01 04 00 00 00 00 50 12 ... S1F4 SystemBytes 0x5012 (52 bytes)
RX 2를 뜯어보면 길이 00 00 00 0A = 10, 즉 헤더만 있고 바디가 없다. 바이트 2 07은 W-bit 0 + Stream 7, 바이트 3 00이 Function 0이다. SxF0은 해당 트랜잭션을 중단한다는 응답이고, 중요한 건 중단된 요청의 SystemBytes를 그대로 달고 온다는 점이다. 0x5011이 붙어 있으니 어느 요청이 거절됐는지 호스트가 안다.
응답 개수는 3건으로 맞는다. 그래서 순서로 짝짓는 코드도 이 배치에서는 살아남는다. 살아남는 게 더 나쁘다. W-bit 0 섞인 배치에서만 터지는 버그가 되고, 재현 조건은 "폴링 세트에 S1F1을 fire-and-forget으로 끼운 날"이 된다.
이 리스너의 순서는 보장이 아니다
세 캡처 모두 응답이 요청 순서대로 왔다. 리스너 코드를 보면 이유가 있다. 수신 버퍼를 while 루프로 돌면서 완성된 프레임 하나를 잘라 그 자리에서 응답을 쓴다. 한 소켓 이벤트 안에서 직렬로 처리하니 순서가 뒤집힐 구조가 아니다.
이건 이 도구의 구현이고, HSMS가 보장하는 성질이 아니다. E37은 동시에 열린 트랜잭션 수를 제한하지 않고, 응답이 요청 순서로 와야 한다고 요구하지도 않는다. 애플리케이션 처리 시간이 메시지마다 다른 실제 장비라면 — 레시피를 디스크에서 읽어야 하는 S7과 메모리 값만 읽는 S1F3이 같이 열려 있을 때 — 뒤에 보낸 쪽이 먼저 돌아오는 게 자연스럽다. 순서가 뒤집히는 실제 장비 캡처는 내가 확인하지 못했다. 여기서 확인한 건 "순서에 의존하지 않아도 짝짓기가 되는 필드가 헤더에 있다"는 것까지다.
T3도 같은 이야기다. T3는 W-bit를 세운 데이터 메시지 하나에 대한 응답 대기 시간이고 기본값은 45초(범위 1–120)다. 열린 트랜잭션이 네 개면 타이머도 네 개다. "요청 보냄" 시점에 하나 켜는 전역 타이머로 만들면, 먼저 끝난 트랜잭션이 남의 타이머를 끄게 된다.
호스트 코드에 넣을 것
- 트랜잭션 테이블의 키는 SessionID + SystemBytes다. 도착 순서도, 큐의 머리도 아니다.
- W-bit 0으로 보낸 요청은 테이블에 넣지 않는다. 넣으면 반드시 T3로 터진다.
- SxF0을 받으면 그 SystemBytes의 트랜잭션을 실패로 닫는다. 다음 응답으로 넘기면 거기서부터 전부 밀린다.
- 타이머는 열린 트랜잭션마다 하나다.
- SystemBytes는 그 연결에서 겹치지 않게 발급한다. 동시에 네 개를 열어 두는 순간 겹침 확률이 올라가고, 겹치면 응답을 가를 방법이 없어진다. 발급 규칙은 HSMS SystemBytes 유일성 쪽에 정리해 뒀다.
- 응답 개수를 세지 말 것. 세 캡처 중 두 개에서 요청 수와 응답 수가 달랐다.
한 번의 write()에 요청 네 개를 붙여 보내는 것 자체는 문제가 없다. 커널은 그걸 한 세그먼트로 보내고 수신 측은 프레임 네 개로 센다 — 길이 접두어 기준의 프레임 분해는 TCP 하나의 읽기가 HSMS 메시지 하나가 아니다 쪽에 따로 뜯어 놨다. 깨지는 건 전송이 아니라 짝짓기다.
위 바이트를 직접 재현하려면 SECS/GEM 시뮬레이터의 passive 리스너를 켜고 소켓을 열면 된다. W-bit 하나 내려 보는 데 1분이면 되고, 그 1분이 "장비가 응답을 안 준다"는 티켓 하나를 줄인다.