# 102 — 채택과 명세: 명세가 찾은 구멍 넷, ngtcp2와의 M6 2026-09-29. [설계 011](../design/011-pure-transport.md)의 5단계다. 링크를 노드 사이 전달의 기본으로 정하고, 다른 구현이 맞붙을 수 있는 명세 [012](../design/012-link-protocol.md)를 쓴다. 착수 기준: - **바꿀 결정.** 노드 사이 전달의 기본을 연결 없는 링크로 정한다. 명세를 문서로 둔다. - **최소 확인.** - 명세의 바이트와 규칙이 코드와 같다. 명세만 보고 따로 쓴 받는 쪽이 실제 노드와 맞붙어야 한다. - [101](101-remaining-limits.md)이 남긴 공백을 채운다. M6(손실, 재정렬, 병목)를 ngtcp2와도 잰다. - **중단 기준.** - 독립 구현이 맞붙지 않으면, 명세나 코드를 고친 뒤에 채택한다. - M6에서 ngtcp2보다 가드레일(011: 10 %)을 넘게 나쁘면 채택을 미룬다. ## 쉬운 말로 - 알아낸 것: - **명세.** 012가 머리, 키 유도, 프레임 13종, 두 쪽의 규칙, 명세의 값과 지역 선택, 보안 성질을 적는다. - 명세만 보고 파이썬으로 받는 쪽을 따로 썼다. `cryptography` 패키지만 쓰고 `link/`의 코드는 쓰지 않았다. - 그 받는 쪽이 `pure-serve` 노드와 맞붙는다. 주문 200건이 모두 저장되고, 첫 패킷 하나 뒤로는 두 쪽이 따로 셈한 에폭 1로 봉인된다. - **명세를 쓰다가 구멍 넷을 찾았다.** 규칙을 문장으로 옮기니 코드가 말하지 않던 경우가 드러났다. - 받는 쪽이 받지 않는 스트림(허용되지 않은 source, 없는 입력, 다른 shape)을 보내는 쪽은 아무 말도 듣지 못했다. 쌓인 기록을 매초 끝없이 다시 보냈고, 운영자는 아무것도 보지 못했다. 이제 REFUSED로 이유를 듣고, 1초에서 64초까지 늘어나는 간격으로 패킷 하나씩만 시도한다. - 보내는 쪽은 64 MiB까지의 기록을 받았지만, 받는 쪽은 16 MiB까지만 쥘 수 있었다. 그 사이 크기의 기록은 영원히 다시 갔다. - 받는 쪽은 피어마다 run 자리를 둘만 두었다. 한 노드의 출력 셋이 같은 노드로 가면 run들이 서로 자리를 밀어내, 답이 엉뚱한 run으로 갔다. 검사에서 run 키를 3번이 아니라 7번 만들었고, 저장까지 16 ms가 아니라 1,016 ms가 걸렸다. - 메모는 피어마다 run 하나의 에폭만 들었다. 그 출력 셋이 에폭 1에 있을 때 받는 쪽이 다시 뜨면 둘이 LOST를 받고 에폭을 다시 시작했다. 이제 LOST도 X25519도 없다. - **허가.** 받는 쪽의 허가가 기록 1,024건으로 고정되어, 링크 하나가 RTT마다 1,024건 넘게 나를 수 없었다. 100 ms 경로에서 61 B 기록은 초당 1만 건이 끝이었다(경로가 나를 수 있는 양의 1/9). 이제 허가를 받는 쪽 메모리에 맞추어, 같은 경로에서 초당 5만 6천 건이다. - **M6와 ngtcp2.** 세 구현을 같은 병목(50 Mbit/s), 같은 경로에서 번갈아 쟀다. - 재정렬만 있을 때 Pure가 가장 많이 나른다(45 Mbit/s). ngtcp2는 29–47, quinn은 1–2다. - 손실만 있을 때는 셋이 같다. - 손실도 재정렬도 없을 때는 Pure가 3–5 % 적다. 전부 틀의 바이트다. Pure는 패킷마다 틀이 약 112 B, QUIC은 약 41 B다. - 손실과 재정렬이 함께 있을 때는 ngtcp2가 1.3–3.6배 나른다. ngtcp2는 재정렬로 잘못 본 손실을 되돌리면서, 그 앞의 진짜 손실이 줄인 창까지 함께 되돌린다. ngtcp2의 로그와 코드로 확인했다(아래). Pure는 한 에피소드의 손실이 모두 늦게 온 것으로 밝혀질 때만 되돌린다. - 아직 모르는 것: - 다른 사람이 명세만으로 만든 구현은 아직 없다. 파이썬 받는 쪽은 명세를 쓴 사람이 썼다. 다만 코드를 옮기지 않고 명세의 문장대로 썼다. - 보내는 쪽의 독립 구현은 없다. 파이썬 쪽은 받는 쪽이다. - 사용 경험의 차이: - 잘못 배선한 링크는 보내는 노드의 로그 한 줄로 드러난다. HTTP 전달이 받는 쪽의 거절을 알리는 것과 같은 모양이다. 종료 통계에도 `link_refused`와 `listener_refused`로 남는다. - 한 노드의 여러 출력을 같은 노드로 보내도 된다. 받는 쪽이 다시 떠도 모두 그대로 이어진다. ## 한 것 ### 명세 012 - 012는 바이트 하나하나를 코드와 맞춰 가며 썼다. - 머리 21 B: 판과 에폭, 키 id(FNV-1a), run, 패킷 번호. - 키 유도: HKDF-SHA256의 salt와 info 문자열, 누가 from이고 누가 to인지, 어느 run이 들어가는지. - 논스, 에폭의 사슬, 프레임 13종의 배치. - 판은 선 위에서 협상하지 않는다. 판은 받는 쪽의 키와 함께 배선에서 온다. 다음 판은 판 번호와 키 유도 문자열을 함께 바꾸므로, 판이 다른 두 구현은 서로의 패킷을 열 수 없다. - 값은 둘로 나눴다. 상대가 알아야 하는 명세의 값(데이터그램, 허가, 증폭 한도, LOST의 크기 조건)과, 한쪽이 혼자 정해도 되는 지역 선택(혼잡 제어, PTO, 비율 한도, 쉬는 시간)이다. - 선 위의 알림은 LOST와 REFUSED 둘이고, 둘 다 인증된다. 그 밖의 실패에는 답하지 않는다. ### 독립 구현 `tests/check.py`의 `server_links_independent`가 파이썬 받는 쪽이다. 명세의 다음 부분을 모두 거친다. - 정적 비밀(X25519의 32 B 그대로), 패킷 키(HKDF, info), 논스, 머리를 추가 인증 데이터로 쓰는 AES-128-GCM. - 프레임 읽기: STREAM, RECORDS, RECORD, OFFER, RESPONSE, PING, PADDING. - 답 만들기: CHALLENGE, ACCEPT(되풀이 4 B), ACK(구간), STORED. 답 run과 보내는 쪽 run을 넣은 답의 키. - 에폭 1의 비밀: 파이썬 쪽의 새 키 쌍과 노드의 OFFER로 E(1)을 따로 셈한다. 노드의 패킷 199개가 그 키로 열린다. - 새 주소의 확인(CHALLENGE, RESPONSE)과 증폭 한도(3배). 다섯 번 돌려 모두 같았다. 0.8초에 끝난다. ### REFUSED - **받는 쪽.** STREAM을 받지 않으면 이유와 함께 그 run의 다음 답에 REFUSED(0x0c)를 넣는다. 한 차례에 한 번이다. - 이유 1: 그 이름의 메시지 입력이 없다. - 이유 2: 입력의 shape가 다르다. - 이유 3: 이 키는 그 source로 보낼 수 없다. - 호스트의 입력 콜백이 1과 2를 가른다(`-PURE_LINK_OTHER_SHAPE`). 전의 `-1`은 그대로 1이다. - **보내는 쪽.** 기록을 보내지 않고 기다린다. 기다린 뒤 기록을 실은 패킷 하나로 시도한다. - 기다림은 1초에서 시작해 두 배씩, 64초까지 는다. - 그 패킷을 확인한 답에 REFUSED가 없으면 거절이 끝난다. STORED를 들어도 끝난다. - **처음 만든 판의 구멍.** 처음에는 STORED를 들어야만 거절을 풀었다. 그런데 시도한 패킷이 큰 기록의 앞 조각만 실으면, 받는 쪽은 저장할 것이 없어 STORED를 보내지 않는다. 보내는 쪽은 STORED만 기다리며 멈췄다. 검사가 이것을 잡았다. 그래서 "시도한 패킷 자체가 REFUSED 없이 확인됨"으로 푼다. PING만 든 탐침의 확인으로 잘못 풀지 않도록, 시도한 패킷의 번호가 확인됐는지를 본다. - **검사.** - test-link: 세 가지 이유가 각각 90초에 7번 거절되고, 패킷은 9개다. 입력이 없다가 10초에 생긴 스트림은 그 뒤 첫 시도(15초)에 풀려 200건이 모두 저장된다. - check.py: 없는 입력으로 링크를 건 노드가 로그에 한 번 알리고, 2.5초 동안 패킷 2개만 보낸다. - REFUSED 전에는 같은 90초에 거절된 스트림 하나가 패킷 22,593–23,225개를 보냈고, 기록을 40,570–52,976번 다시 보냈다. 받았다고 들은 기록이 1초 안에 저장되지 않으면 모두 다시 보내는 규칙이 끝없이 돌았다. 옛 커밋(78231dc)의 라이브러리에 같은 검사를 붙여 쟀다. ### 기록의 크기 - 보내는 쪽의 상한(64 MiB)과 받는 쪽이 한 스트림에서 쥐는 양(16 MiB)이 달랐다. 16 MiB가 넘는 기록은 받는 쪽이 버리고, 보내는 쪽은 저장 소식이 없으니 다시 보냈다. - 이제 둘 다 `PURE_LINK_RECORD_MOST`(16 MiB)다. 명세의 값이다. test-link가 16 MiB 기록 하나를 한 번에 보내고(가상 59 ms), 1 B 더 큰 것은 큐에 받지 않는 것을 본다. ### 피어의 run 자리 - 받는 쪽은 피어마다 run 자리 둘을 두었다. 보내는 쪽의 run과, 그것이 다시 떴을 때의 새 run을 위한 것이었다. - 그런데 한 노드의 출력 여럿이 같은 노드로 가면, 출력마다 보내는 쪽이 따로 있고 run도 따로다. 셋이면 자리 둘을 서로 밀어낸다. - 밀려난 run의 패킷은 키를 다시 만든다. - 답 목록이 가리키던 자리를 다른 run이 차지해, 답이 엉뚱한 run으로 간다. - 이제 가장 오래 조용한 run도 아직 살아 있으면(쉬는 시간 안에 보냈으면) 자리를 하나 더 만든다. 피어당 64개까지다. 자리는 피어가 쉬어 놓일 때까지 그대로 두므로, 가리키는 것들이 늘 맞다. - test-link: 한 키로 세 출력이 1,000건씩 보낸다. 고치기 전에는 run 키를 7번 만들었고, 저장까지 가상 1,016 ms가 걸렸다. 고친 뒤에는 3번, 16 ms다. ### 메모의 곁 자리 - 메모는 피어의 자리에 run 하나의 에폭만 두었다. 여러 run이 함께 보내는 피어는 받는 쪽이 다시 뜬 뒤 마지막으로 받아들인 run 말고는 모두 LOST를 받았다. 에폭 0으로 돌아가므로, 기록이 한 왕복 동안 정적 키 아래로 가고 X25519가 더 든다. - 이제 피어의 자리에 든 run이 아직 보내는 동안, 다른 run의 에폭은 곁의 자리에 둔다. - 곁 자리는 1,024개이고 파일 머리에 있다(160 KiB). 피어와 run으로 두 자리를 찾아, 가진 것, 빈 것, 첫째 순으로 고른다. - run이 피어의 자리로 옮겨 가면 곁 자리를 지운다. 에폭의 비밀은 다음 에폭이 덮을 때까지만 둔다는 규칙을 지키려는 것이다. - 형식이 바뀌어 메모는 PLM3이다. PLM2 메모는 처음부터 다시 만든다(피어마다 X25519 한 번). - test-link: 세 출력이 모두 에폭 1에 든 뒤 받는 쪽을 kill -9처럼 다시 띄운다. 고치기 전에는 둘이 LOST를 받고 수락 한 번(비밀 2개)이 들었다. 고친 뒤에는 둘 다 0이다. ### 허가를 기록의 크기에 맞춤 M6의 100 ms 경로에서 Pure가 20 ms 때보다 패킷을 28 % 더 보냈다(107,968 대 84,106). goodput은 거의 같았고 손실은 0이었다. 패킷이 덜 찼다는 뜻이다. - **원인.** 받는 쪽의 허가가 기록 1,024건으로 고정되어 있었다. - 100 ms에 50 Mbit/s이면 대역폭×지연과 병목 큐에 든 기록이 1,024건을 넘는다. - 그래서 보내는 쪽은 STORED가 조금씩 연 만큼만, 한두 건씩 든 패킷을 보냈다. - **더 큰 한계.** 링크 하나가 RTT마다 1,024건을 넘게 나를 수 없었다. 시뮬레이터(가상 시간, 실제 프로토콜 코드)로 쟀다. | 기록 | RTT 10 ms | RTT 100 ms | |---|---:|---:| | 61 B | 90,814건/s(44 Mbit/s, 대역폭에 걸림) | 9,953건/s(4.9 Mbit/s) | | 1 KiB, 보낸 패킷 | 74,890 | 99,477 | 61 B 기록은 100 ms 경로에서 대역폭과 상관없이 초당 1만 건이 상한이었다. 경로가 나를 수 있는 양의 1/9이다. - **고침.** 받는 쪽은 메모리 한도(스트림당 16 MiB)를 따로 지키고 있다. 그러니 허가를 건수로 고정할 까닭이 없다. - 허가는 남은 자리를 지난 전달의 기록당 바이트로 나눈 것이다. 1–65,536건이고, 전달이 없으면 전처럼 1,024건이다. - 받는 범위는 들은 번호 + 65,536까지다. 보내는 쪽이 따르는 상한과 같다. - 메모리를 넘는 기록은 전처럼 받지 않고, 보내는 쪽이 다시 보낸다. - **고친 뒤.** | | 전 | 후 | |---|---:|---:| | 시뮬레이터, 61 B, RTT 100 ms | 9,953건/s | 56,296건/s | | 시뮬레이터, 61 B, RTT 10 ms | 90,814건/s, 손실 0 | 87,130건/s, 손실 1,371 | | 시뮬레이터, 1 KiB, RTT 100 ms | 44.1 Mbit/s, 패킷 99,477 | 44.0 Mbit/s, 패킷 75,938 | | M6, 1 KiB, RTT 100 ms, 손실 0 | 44.9 Mbit/s, 패킷 107,968 | 45.4 Mbit/s, 패킷 83,566 | - 이제는 허가가 아니라 혼잡 창이 병목의 큐를 채운다. 그래서 큐에서 버려지는 손실이 생긴다. 전에는 허가가 뜻하지 않게 창을 대신 묶어 그것을 가렸다. ### M6: ngtcp2 - 101의 [drain.c](../../research/experiments/019-idle-links/ngtcp2/drain.c)에 quinn 하네스와 같은 두 모드를 더했다. - 속도 맞춤: 초당 RATE개, 각 메시지 앞 8 B에 쓴 시각을 넣는다. - 시간 제한: SECONDS가 지나면 더 쓰지 않는다. - 받는 쪽은 quinn과 같은 줄을 낸다. 100 ms마다 읽은 메시지 수와, 속도를 맞췄을 때 걸린 시간이다. - ngtcp2는 스트림 바이트를 확인될 때까지 앱이 들고 있어야 한다. 속도 맞춤 모드는 모든 메시지 자리를 미리 잡아 두고, 제 시각에 시각을 찍어 스트림의 끝을 늘린다. - 020의 run.py가 ngtcp2를 세 번째로 돌린다. 같은 병목 뒤의 포트 40005다. 경우마다 Pure, quinn, ngtcp2를 번갈아 돌린다. - **하네스의 구멍.** 처음 돌렸을 때 재정렬이 있는 경우에서 ngtcp2가 멈췄다. - OpenSSL 3.5의 ClientHello는 기본으로 ML-KEM 혼합 키를 실어 Initial 패킷 둘로 나뉜다. - 경로가 둘째를 먼저 가져다주면 ngtcp2의 서버는 Retry를 요구하는데, 하네스가 그것을 처리하지 않았다. - 클라이언트가 X25519만 내게 했다. Pure의 링크도 X25519로 합의한다. 서버는 붙들 수 없는 연결을 놓고, 클라이언트의 다음 Initial로 새로 시작한다. ## 결과 **M6** (두 라운드, 경우마다 세 구현을 번갈아, 라운드마다의 값; `research/experiments/020-lossy-paths/2026-09-29-paths-ngtcp2-linux-aarch64.jsonl`). Pure의 값은 허가를 고치기 전이다. 고친 뒤 100 ms 행은 45.4, 45.4, 1.5, 1.4, 0.5, 0.6 Mbit/s이고, 나머지 행은 잡음 안에서 같다. 대용량(1 KiB 기록, Mbit/s): | RTT | 손실 | 흔들림 | Pure | quinn | ngtcp2 | |---:|---:|---:|---|---|---| | 20 | 0 % | 0 ms | 45.4 / 45.4 | 46.8 / 46.8 | 47.2 / 47.2 | | 20 | 0 % | 3 ms | 45.4 / 45.4 | 1.9 / 2.1 | 40.0 / 39.8 | | 20 | 1 % | 0 ms | 6.6 / 7.0 | 6.9 / 7.6 | 7.2 / 7.5 | | 20 | 1 % | 3 ms | 7.0 / 7.6 | 1.6 / 1.8 | 25.5 / 26.2 | | 20 | 5 % | 0 ms | 2.8 / 3.0 | 2.8 / 3.1 | 3.0 / 3.3 | | 20 | 5 % | 3 ms | 3.1 / 3.1 | 1.7 / 1.4 | 6.0 / 9.3 | | 100 | 0 % | 0 ms | 44.9 / 44.9 | 47.3 / 47.3 | 47.2 / 47.2 | | 100 | 0 % | 3 ms | 45.2 / 45.2 | 1.0 / 1.3 | 29.1 / 47.2 | | 100 | 1 % | 0 ms | 1.3 / 1.6 | 1.6 / 1.3 | 1.7 / 1.2 | | 100 | 1 % | 3 ms | 1.4 / 1.4 | 0.8 / 0.8 | 1.8 / 2.5 | | 100 | 5 % | 0 ms | 0.6 / 0.6 | 0.6 / 0.5 | 0.6 / 0.7 | | 100 | 5 % | 3 ms | 0.6 / 0.7 | 0.4 / 0.4 | 1.2 / 0.9 | 속도를 맞춘 전송(초당 50건, 쓴 때부터 받는 쪽이 넘길 때까지 p50 / p99 / 최대 ms, 두 라운드의 중앙값): | RTT | 손실 | Pure | quinn | ngtcp2 | |---:|---:|---|---|---| | 20 | 1 % | 10.0 / 50.1 / 60.2 | 10.1 / 50.3 / 70.4 | 10.0 / 40.1 / 53.2 | | 20 | 5 % | 10.0 / 61.7 / 110.1 | 10.1 / 84.0 / 122.6 | 10.0 / 71.2 / 110.6 | | 100 | 1 % | 50.0 / 130.1 / 230.1 | 50.1 / 180.3 / 236.3 | 50.0 / 173.7 / 210.5 | | 100 | 5 % | 60.2 / 411.5 / 467.9 | 70.7 / 406.3 / 490.5 | 71.9 / 292.0 / 366.8 | TCP CUBIC 옆에서의 몫(흔들림 1 ms, 라운드마다): 20 ms에서 Pure 0.53, 0.47, ngtcp2 0.52, 0.47, TCP끼리 0.53, 0.50이다. 100 ms에서 Pure 0.57, 0.58, ngtcp2 0.61, 0.56, TCP끼리 0.47, 0.49다. quinn은 흔들림 1 ms에도 무너져 0.02–0.07이다. **ngtcp2가 손실과 재정렬 아래에서 더 나르는 까닭.** 무작위 손실 1 %, RTT 20 ms에서 Reno 계열이 낼 수 있는 이론값(Mathis 식)은 약 7 Mbit/s다. 재정렬이 없을 때는 셋 다 거기에 있다. 재정렬이 더해지면 ngtcp2만 25 Mbit/s로 오른다. - ngtcp2의 CUBIC은 혼잡 사건 때 되돌릴 창을 저장한다. 그런데 지금 창이 저장된 값보다 클 때만 덮어쓰고, 잘못 본 손실을 되돌릴 때에만 지운다(`lib/ngtcp2_cc.c`, 1.25.0의 478행과 551행). - 진짜 손실의 패킷은 PTO의 3배가 지나면 손실 목록에서 빠진다(`ngtcp2_conn_remove_lost_pkt`). 그 뒤에 재정렬로 잘못 본 손실 하나가 늦게 확인되면, 목록의 손실이 모두 확인된 것으로 보고 저장된 창을 되살린다. 저장된 창은 그 앞의 진짜 손실이 줄이기 전의 것이다. - 로그로 셌다(5초 × 2, 같은 경로). - 재정렬이 없으면 창을 55번 줄이고 한 번도 되돌리지 않는다. - 재정렬이 있으면 226번 줄이고 34번 되돌린다. 34번 가운데 22번은 창을 바로 앞 감소 전보다 크게 되돌렸다. 그 앞의 진짜 손실이 줄인 창까지 지운 것이다. - 그러니 이 행의 차이는 손실에 반응하지 않은 몫이다. 경로를 함께 쓰는 흐름이 있으면 그만큼 덜 비킨다. Pure는 한 에피소드의 손실이 모두 늦게 온 것으로 밝혀질 때만 되돌린다(097). **틀의 바이트.** 손실도 재정렬도 없는 병목에서 Pure는 ngtcp2보다 3–5 % 적게 나른다. 셈하면 전부 틀이다. - Pure의 가득 찬 1,452 B 패킷의 틀은 약 112 B다. - 머리와 태그 37 B - STREAM 25 B(이름 두 개) - 앞 패킷에서 이어진 조각의 RECORD 19 B - RECORDS 10 B와 크기 2 B - 다음 패킷으로 넘어가는 조각의 RECORD 19 B - QUIC은 짧은 머리와 태그 약 27 B, STREAM 프레임 약 8 B, 메시지마다 길이 4 B로 약 41 B다. - 셈으로는 Pure가 ngtcp2의 0.95배를 나른다. 잰 값은 0.96배다. ## 판정 - 명세가 코드와 같다는 것을 독립 구현으로 보였다. 파이썬 받는 쪽이 에폭 바꿈까지 맞붙는다. - 명세를 쓰다가 찾은 구멍 넷과 M6가 드러낸 허가의 한계를 고쳤다. 각각 옛 코드에서 실패하고 새 코드에서 통과하는 검사가 있다. - M6에서 ngtcp2와 대었다. 재정렬 아래에서 Pure가 가장 많이 나르고, 손실만 있을 때 셋이 같다. 틀의 바이트로 3–5 % 뒤지고, 손실과 재정렬이 겹칠 때 ngtcp2가 더 나르는 것은 손실에 덜 반응해서다. 011의 가드레일(TCP와의 몫 ±25 %)을 셋 다 지킨다 (quinn은 흔들림에 무너진다). - 채택 결정(011 5단계)은 그대로다. ## 한계와 다음 - 파이썬 받는 쪽은 명세를 쓴 사람이 썼다. 명세의 모호함은 다른 사람이 구현할 때 더 드러날 것이다. - 보내는 쪽의 독립 구현은 없다. - 틀의 바이트는 판 1에서 줄일 수 있다. 약 112 B를 약 45 B로 줄이면 QUIC과 비긴다. - 받는 쪽이 그 run의 스트림을 안다는 것을 들은 뒤에는 STREAM을 뺀다(101에서 시도했던 설계, 25 B). - 패킷의 기록 바이트를 조각이든 온전한 기록이든 한 프레임으로 잇는다(약 40 B). - 패킷 번호를 줄여 싣는다(약 6 B). - 100 ms, 5 % 손실의 속도 맞춤 전송에서 p99는 ngtcp2가 낮다(292 대 412 ms). 라운드마다 크게 흔들리는 경우이고(097에서도 Pure 330–884 ms, quinn 310–752 ms), 원인은 아직 보지 않았다.