aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/design/011-pure-transport.md10
-rw-r--r--docs/research/094-idle-links.md4
-rw-r--r--docs/research/095-restart-and-first-contact.md131
-rw-r--r--research/experiments/019-idle-links/2026-09-28-first-contact-linux-aarch64.jsonl3
-rw-r--r--research/experiments/019-idle-links/2026-09-28-restart-links-linux-aarch64.jsonl6
-rw-r--r--research/experiments/019-idle-links/2026-09-28-restart-links-sd-linux-aarch64.jsonl1
-rw-r--r--research/experiments/019-idle-links/2026-09-28-restart-quinn-linux-aarch64.jsonl3
-rw-r--r--research/experiments/019-idle-links/quic/src/main.rs71
-rw-r--r--research/experiments/019-idle-links/run.py142
9 files changed, 341 insertions, 30 deletions
diff --git a/docs/design/011-pure-transport.md b/docs/design/011-pure-transport.md
index d27aa5a..a49d702 100644
--- a/docs/design/011-pure-transport.md
+++ b/docs/design/011-pure-transport.md
@@ -1,6 +1,6 @@
# 011 — 연결 없는 전송: 전송의 상태를 앱의 데이터로
-작성일: 2026-09-28(같은 날 첫 판을 버리고 다시 씀). 상태: 설계. 1단계를 구현했고 M1을 쟀다.
+작성일: 2026-09-28(같은 날 첫 판을 버리고 다시 씀). 상태: 설계. 1단계를 구현했고 M1, M2, M4를 쟀다.
[외부 피드백 002](../outside-feedback/feedback-002.md)의 "QUIC을 능가할 자체 raw socket 프로토콜"에서 시작한다.
1단계(링크 시제품과 kill -9 검사)는 [093](../research/093-connectionless-link.md)에 있다. 어느 쪽을 죽여도 핸드셰이크 없이
@@ -10,8 +10,12 @@
M1은 [094](../research/094-idle-links.md)에 있다. 쉬는 링크 하나가 노드 메모리 132 B를 쓴다. 허용 목록 73 B와 들은 번호
약 60 B이고, 전송 상태는 0이다. quinn의 쉬는 연결은 38,106 B다. 1/289로 중단 기준(1/100)을 넘는다. 설계가 말한 "링크의
-행"은 앱이 이미 가진 들은 번호였다. 그래서 들은 번호를 목록으로 바꿨고, 저장소의 한 항목은 약 20 B다. 남은 걸림돌은 M2다.
-활성 상태를 비우면 비밀도 잊으므로, 재시작한 노드는 링크마다 X25519를 한 번 한다.
+행"은 앱이 이미 가진 들은 번호였다. 그래서 들은 번호를 목록으로 바꿨고, 저장소의 한 항목은 약 20 B다.
+
+M2와 M4는 [095](../research/095-restart-and-first-contact.md)에 있다. 비밀은 노드 키 옆 파일에 적어 둔다(메모). 기기 1만
+개가 초당 2,000건을 보내는 중에 받는 쪽을 kill -9 해도 저장이 멈춘 100 ms 칸이 없다. 0.2초 만에 따라잡고, 재시작에 비대칭
+연산이 들지 않으며, 잃거나 겹친 기록이 없다. quinn은 1초 멈추고 10.9초 뒤에 따라잡는다. 그동안 핸드셰이크 1만 번에 서버가 CPU
+약 7초를 더 쓴다. 처음 만남은 1 RTT로, QUIC의 2 RTT의 절반이다.
## 첫 판을 버린 까닭
diff --git a/docs/research/094-idle-links.md b/docs/research/094-idle-links.md
index 57fc736..573db98 100644
--- a/docs/research/094-idle-links.md
+++ b/docs/research/094-idle-links.md
@@ -133,8 +133,8 @@ H1의 뒷부분("프로세스 메모리는 활성 링크 수에만 비례")은 �
## 한계와 다음
- **재시작의 비대칭 연산.** 활성 상태를 비우면 비밀도 잊는다. 그래서 재시작한 노드는 링크마다 X25519를 한 번 한다(169 µs).
- 011의 M2 중단 기준("이어지는 데 링크 수에 비례하는 비대칭 연산")에 걸린다. 다음 일은 비밀의 메모다. 키 파일 옆 파일에
- 보내는 이마다 64 B 칸을 두고 매핑한다. 쉬는 링크의 칸은 상주하지 않는다.
+ 011의 M2 중단 기준("이어지는 데 링크 수에 비례하는 비대칭 연산")에 걸린다. [095](095-restart-and-first-contact.md)에서 비밀의
+ 메모로 풀었다. 키 파일 옆 파일에 보내는 이마다 64 B 칸을 두고 매핑한다. 쉬는 링크의 칸은 상주하지 않는다.
- **받는 쪽의 처리량.** 새 보내는 이를 받는 속도는 초당 약 3,700이다. 노드 CPU의 대부분이 X25519다.
- **보내는 쪽.** 링크마다 스레드와 소켓이 하나씩이다. 노드가 기기 100만 대에 보내는 방향은 아직 안 된다. 보내는 쪽도 한
스레드와 한 소켓에 여러 링크를 둬야 한다.
diff --git a/docs/research/095-restart-and-first-contact.md b/docs/research/095-restart-and-first-contact.md
new file mode 100644
index 0000000..eb0249d
--- /dev/null
+++ b/docs/research/095-restart-and-first-contact.md
@@ -0,0 +1,131 @@
+# 095 — 재시작과 처음 만남: 링크 1만 개가 kill -9를 모른다
+
+2026-09-28. [설계 011](../design/011-pure-transport.md)의 M2와 M4다. [094](094-idle-links.md)의 부하 생성기와 quinn
+하네스를 그대로 쓴다.
+
+착수 기준:
+
+- **바꿀 결정.** 011의 H2다. 어느 노드를 kill -9 해도 다시 뜬 뒤 첫 패킷으로 이어지고, 핸드셰이크도 잃거나 겹치는 기록도 없다.
+ 중단 기준은 둘이다.
+ - 재시작 뒤 핸드셰이크 없이 이어지지 않는다.
+ - 이어지는 데 링크 수에 비례하는 비대칭 연산이 든다.
+ - 둘 중 하나라도 그러면 다시 설계한다.
+- **최소 확인.**
+ - M2: 기기 1만 개가 5초마다 주문 하나를 보낸다. 받는 쪽이 초당 2,000건을 받는다. 20초쯤에 받는 쪽을 kill -9 하고 곧바로
+ 다시 띄운다. 저장이 멈춘 시간, 밀린 것을 따라잡는 시간, 양쪽 CPU, 비대칭 연산 수, 잃음과 겹침을 본다. quinn도 같은 부하로
+ 한다. 연결 1만 개가 다시 맺어진다.
+ - M4: 처음 보는 상대에게 주문 하나를 보내 저장됐다는 말을 받기까지 걸리는 왕복 수를 본다.
+- **중단 기준.** 위와 같다.
+
+## 쉬운 말로
+
+- 알아낸 것:
+ - 받는 노드를 kill -9 하고 다시 띄워도 기기 1만 개는 거의 모른다. 100 ms 단위로 저장이 멈춘 때가 없었다. 밀린 것은 0.2초
+ 만에 따라잡았다. 다시 보낸 주문은 34건이고, 8만 건이 모두 정확히 한 번 적용됐다. 재시작한 노드의 비대칭 연산은 0이다.
+ - quinn은 같은 일에서 1초 동안 멈췄다. 연결 1만 개를 다시 맺느라 10.9초 뒤에야 따라잡았다. 그동안 핸드셰이크에 서버가
+ CPU를 약 7초, 클라이언트들이 약 8초 더 썼다.
+ - 처음 보는 상대와 Pure는 1 RTT에 끝나고, QUIC은 2 RTT가 든다. 첫 패킷이 이미 봉인된 기록이기 때문이다.
+- 아직 모르는 것: 전달당 CPU와 처리량(M5), 손실과 병목 아래의 공정성(M6), 저장소 옮기기(M3).
+- 사용 경험의 차이: 서버를 배포하거나 죽었다 살아나도, 붙어 있던 기기와 사용자는 모른다. 재연결 폭주도 없다.
+
+## 한 것
+
+### 비밀의 메모
+
+[094](094-idle-links.md)의 받는 쪽은 쉬는 보내는 이의 활성 상태를 비운다. 그러면 비밀도 잊는다. 그래서 재시작한 노드는
+돌아오는 보내는 이마다 X25519를 한 번 했다. 이 Pi의 OpenSSL로 169 µs다. 이것이 M2의 두 번째 중단 기준에 걸렸다.
+
+비밀은 이제 노드 키 옆의 파일(`KEY.links`, 0600)에 적어 둔다.
+
+- 보내는 이마다 허용한 순서대로 64 B 칸이 하나씩 있다. 칸에는 키의 앞 28 B, 검사값(키와 비밀의 FNV-1a), 비밀이 든다.
+- 파일은 매핑한다. 쉬는 보내는 이의 칸은 메모리에 올라와 있지 않다.
+- 비밀을 먼저 쓰고, 칸을 그 키의 것으로 만드는 키는 나중에 쓴다. 검사값이 맞지 않는 칸은 다시 만든다. 손으로 한 칸의 비밀을
+ 바꿔 봤고, 그 칸만 X25519로 다시 만들어졌다.
+- 노드 키가 바뀌면 파일을 처음부터 쓴다.
+- 칸의 비밀은 노드 키와 같은 무게다. 그래서 키 파일과 같이 주인만 읽는다.
+
+연결 없는 모델에서는 이것이 자연스럽다. 비밀은 두 정적 키의 함수라서 언제 계산해도 같다. 메모는 그 함수의 기록일 뿐이다.
+세션이 아니므로 틀려도 다시 계산하면 된다.
+
+### 꾸준히 보내는 기기들
+
+부하 생성기에 `run`을 더했다([links.c](../../research/experiments/019-idle-links/links.c)).
+
+- 기기마다 소켓, 키, outbox가 있다. 주기마다 기록을 하나 만든다.
+- 저장되지 않은 것은 다시 보낸다. 답이 없으면 200 ms에서 시작해 1초까지 기다림을 늘린다.
+- 받는 쪽이 새로 뜨면 답의 run이 바뀐다. 기기는 그 run의 키를 만들어 연다. 기기 쪽의 일은 그것뿐이다.
+- 100 ms마다 저장된 수를 센다.
+
+### quinn 쪽
+
+QUIC에 가장 좋은 조건을 줬다.
+
+- 서비스는 같은 포트에 같은 키로 다시 뜬다. 상태 없는 리셋 키도 그 키에서 만든다. 그래서 새 서비스가 옛 연결의 패킷에
+ 곧바로 리셋을 보낸다.
+- 클라이언트는 1초 안에 답이 없거나 연결이 끊기면 다시 연결해 같은 주문을 보낸다.
+- 서비스는 디스크에 저장하지 않는다. 메모리의 번호만 적는다. 그래서 정확히 한 번은 Pure 쪽만 확인한다.
+- tokio 한 스레드다. Pure의 받는 쪽도 암호는 한 스레드(리스너)가 한다.
+
+## 결과
+
+라즈베리 파이 5이고, 저장소는 tmpfs다(SD 카드는 따로 적었다). 원시 결과는 `research/experiments/019-idle-links/`의
+`2026-09-28-restart-*.jsonl`과 `2026-09-28-first-contact-linux-aarch64.jsonl`에 있다.
+
+**M2.** 기기 또는 클라이언트 1만 개가 5초마다 주문 하나를 보낸다(초당 2,000건). 받는 쪽을 약 21초에 kill -9 하고 곧바로
+다시 띄웠다. 세 라운드의 중앙값이다. SD 카드는 한 라운드다.
+
+| | 다시 뜨기 | 저장이 멈춘 100 ms 칸 | 따라잡기 | 받는 쪽 CPU(재시작 뒤 10초) | 비대칭 연산 | 보내는 쪽 CPU(같은 10초) | 다시 보냄 | 저장까지 p99 / 최대 |
+|---|---:|---:|---:|---:|---:|---:|---:|---:|
+| Pure, 메모 | 14 ms | 0 | 0.2초 | 1.08초 | 0 | 0.99초 | 34건 | 4.1 ms / 206 ms |
+| Pure, 메모 없음 | 15 ms | 0 | 0.3초 | 2.73초 | X25519 10,000 | 0.86초 | 34건 | 4.6 ms / 221 ms |
+| Pure, 메모, SD 카드 | 29 ms | 2 | 0.3초 | 1.06초 | 0 | 0.94초 | 269건 | 111 ms / 267 ms |
+| quinn | 1.5 ms | 10 | 10.9초 | 8.30초 | 핸드셰이크 10,115 | 9.14초 | 연결 10,115개 | 5.3초 / 11.2초 |
+
+- 평소에는 받는 쪽도 보내는 쪽도 초당 약 0.1초의 CPU를 쓴다. 재시작 뒤 10초는 그 10배 안팎이 평소 몫이다.
+ - Pure는 메모가 있으면 재시작 뒤 CPU가 평소와 같다.
+ - 메모가 없으면 1.6초 더 든다. X25519 1만 번(각 0.165 ms)의 값이다.
+ - quinn은 받는 쪽이 7.2초, 보내는 쪽이 8.0초 더 쓴다. 우리 클라이언트는 인증서를 확인하지 않으므로, 실제 클라이언트는 더
+ 든다.
+- Pure의 모든 라운드에서 만든 주문, 저장됐다는 말을 받은 주문, 받는 앱이 적용한 주문의 수가 같았다(8만여 건). 잃거나 겹친 것이
+ 없다.
+- Pure의 기기는 재시작을 알아채지 않는다. 답이 오지 않은 주문을 다시 보낼 뿐이다. 1만 개 가운데 그때 답을 기다리던 30여 개다.
+- SD 카드에서는 저장마다 동기화가 느려 p50이 4.1 ms다. 재시작 중에 밀린 것이 조금 더 많았다(269건). 그래도 0.3초 만에
+ 따라잡았다.
+- quinn은 새 서비스가 옛 연결에 리셋을 보낼 수 있는데도, 첫 1초 동안 저장이 하나도 없었다. 클라이언트의 1초 시한과 같은
+ 길이다. 리셋이 닿지 않은 연결이 시한을 기다렸을 것으로 보지만, 원인은 확인하지 않았다. 그 뒤 핸드셰이크가 서비스의 한
+ 스레드를 채웠다. 스레드를 늘리면 따라잡는 시간은 줄지만, 핸드셰이크 1만 번의 CPU는 그대로다. 서버는 핸드셰이크마다 X25519
+ 둘(임시 키와 합의)과 ECDSA 서명 하나를 한다.
+
+**M4.** 처음 보는 상대 100개에게 하나씩 주문 하나를 보내고, 저장됐다는 말(quinn은 답)을 받기까지를 쟀다. 비특권 네트워크
+이름공간에서 루프백의 UDP만 netem으로 늦췄다.
+
+| 한쪽 지연 | RTT | Pure p50 | Pure 왕복 | quinn p50 | quinn 왕복 |
+|---:|---:|---:|---:|---:|---:|
+| 5 ms | 10 ms | 10.6 ms | 1.06 | 21.0 ms | 2.10 |
+| 25 ms | 50 ms | 50.9 ms | 1.02 | 101.2 ms | 2.02 |
+| 50 ms | 100 ms | 100.7 ms | 1.01 | 201.2 ms | 2.01 |
+
+- Pure는 첫 패킷이 이미 봉인된 기록이다. 받는 쪽이 저장하고 답하면 끝이라 1 RTT다. 받는 쪽의 X25519와 저장은 1 ms 안쪽이다.
+- QUIC은 핸드셰이크에 1 RTT, 주문에 1 RTT가 든다. 세션 티켓의 0-RTT는 다시 만날 때만 되고, 재전송 공격에 열려 있다.
+ 재시작한 서비스는 티켓 키를 저장해 두지 않는 한 티켓을 받지 못한다.
+
+## 판정
+
+- **M2는 통과다.** 받는 쪽을 kill -9 해도 핸드셰이크 없이 이어진다. 메모가 있으면 재시작에 비대칭 연산이 들지 않는다. 기록을
+ 잃거나 겹치지 않았다. 011의 두 중단 기준 모두에 걸리지 않는다.
+- **M4는 1 RTT다.** QUIC의 절반이다. 봉인한 첫 패킷이 기록이기 때문이다. 011이 비교 대상으로 적은 QUIC의 1-RTT는 핸드셰이크만의
+ 값이고, 기록까지는 2 RTT다.
+- H2가 섰다. 093은 정확성을, 여기서는 값을 봤다. 재시작의 값은 연결 없는 모델에서 거의 0이다. 연결이 있는 모델에서는 연결 수에
+ 비례한다.
+
+## 한계와 다음
+
+- **처음 만남은 여전히 X25519 한 번이다.** 새 보내는 이를 받는 속도는 초당 약 3,700이다(094). 쓰레기 패킷으로 이것을 강요하는
+ 공격은 메모로 알려진 키에 대해서는 사라졌다. 모르는 run의 첫 패킷마다 드는 HKDF 두 번은 남는다. 비율 제한은 보안 단계에서
+ 둔다.
+- **메모는 허용 순서에 묶인다.** 허용 목록의 순서가 바뀌면 칸이 맞지 않아 다시 계산한다. 정확성에는 영향이 없다.
+- **QUIC 비교는 quinn 하나다.** ngtcp2는 아직이다.
+- **보내는 쪽.** 링크마다 스레드 하나다(094).
+- 다음은 011의 순서대로다.
+ - 에폭 바꿈(전방 비밀성), 경로 바뀜, 저장소 옮기기(H3, M3), 보안 검사를 한다.
+ - 그 뒤 M5와 M6 가드레일을 잰다.
diff --git a/research/experiments/019-idle-links/2026-09-28-first-contact-linux-aarch64.jsonl b/research/experiments/019-idle-links/2026-09-28-first-contact-linux-aarch64.jsonl
new file mode 100644
index 0000000..a404b1a
--- /dev/null
+++ b/research/experiments/019-idle-links/2026-09-28-first-contact-linux-aarch64.jsonl
@@ -0,0 +1,3 @@
+{"delay_ms_each_way": 5, "senders": 100, "pure_ms_p50": 10.585, "pure_ms_p99": 12.202, "pure_round_trips": 1.06, "quinn_ms_p50": 20.991, "quinn_ms_p99": 21.499, "quinn_round_trips": 2.1}
+{"delay_ms_each_way": 25, "senders": 100, "pure_ms_p50": 50.932, "pure_ms_p99": 52.325, "pure_round_trips": 1.02, "quinn_ms_p50": 101.153, "quinn_ms_p99": 101.799, "quinn_round_trips": 2.02}
+{"delay_ms_each_way": 50, "senders": 100, "pure_ms_p50": 100.735, "pure_ms_p99": 102.419, "pure_round_trips": 1.01, "quinn_ms_p50": 201.192, "quinn_ms_p99": 202.022, "quinn_round_trips": 2.01}
diff --git a/research/experiments/019-idle-links/2026-09-28-restart-links-linux-aarch64.jsonl b/research/experiments/019-idle-links/2026-09-28-restart-links-linux-aarch64.jsonl
new file mode 100644
index 0000000..de7c076
--- /dev/null
+++ b/research/experiments/019-idle-links/2026-09-28-restart-links-linux-aarch64.jsonl
@@ -0,0 +1,6 @@
+{"devices": 10000, "period_ms": 5000, "memo": true, "restart_ms": 13.7, "steady_cpu_ms_per_s": 130.0, "senders_steady_cpu_ms_per_s": 90.0, "recovery_cpu_ms_first_10s": 1040.0, "senders_cpu_ms_first_10s": 980.0, "generator": {"devices": 10000, "made": 80003, "stored": 80003, "sends": 80037, "again": 34, "stored_ms_p50": 0.535, "stored_ms_p99": 4.147, "stored_ms_max": 203.874, "longest_gap_ms": 0, "gap_from_ms": 0}, "timeline_100ms": [183, 207, 191, 208, 191, 212, 193, 193, 211, 193, 194, 212, 195, 197, 195, 214, 196, 196, 196, 214, 197, 198, 198, 199, 197, 198, 198, 200, 200, 200, 199, 216, 200, 201, 200, 200, 183, 199, 218, 182, 201, 202, 200, 200, 200, 202, 203, 202, 201, 201, 213, 200, 198, 200, 199, 200, 200, 200, 199, 199, 200, 200, 200, 200, 199, 201, 201, 201, 201, 201, 200, 199, 201, 191, 200, 200, 199, 202, 201, 201, 201, 202, 202, 194, 208, 191, 201, 203, 202, 201, 200, 201, 191, 204, 202, 204, 191, 203, 201, 202, 199, 189, 213, 202, 197, 199, 203, 193, 200, 200, 200, 202, 202, 201, 200, 202, 191, 201, 201, 199, 200, 201, 202, 203, 201, 196, 205, 195, 205, 194, 206, 196, 205, 196, 193, 206, 195, 205, 205, 194, 205, 194, 205, 195, 206, 194, 200, 207, 196, 196, 203, 203, 191, 208, 200, 201, 197, 205, 189, 202, 202, 203, 204, 193, 203, 203, 190, 199, 206, 205, 191, 201, 203, 200, 201, 202, 202, 190, 204, 201, 201, 203, 199, 194, 202, 202, 202, 193, 203, 204, 201, 201, 191, 202, 205, 194, 202, 205, 192, 205, 200, 200, 200, 203, 197, 202, 203, 190, 199, 201, 165, 203, 238, 203, 193, 205, 203, 193, 196, 201, 208, 199, 197, 199, 198, 198, 200, 200, 211, 189, 200, 200, 201, 205, 200, 200, 196, 207, 194, 207, 194, 199, 205, 196, 196, 206, 195, 209, 196, 198, 204, 203, 192, 203, 202, 201, 192, 204, 203, 193, 202, 200, 200, 198, 210, 199, 200, 200, 199, 201, 198, 199, 201, 200, 199, 199, 201, 201, 198, 202, 199, 201, 200, 198, 200, 203, 194, 205, 203, 197, 193, 206, 197, 204, 194, 206, 197, 195, 205, 197, 202, 201, 200, 201, 203, 203, 190, 206, 201, 192, 209, 200, 199, 192, 201, 200, 199, 207, 201, 201, 196, 201, 203, 196, 203, 200, 198, 200, 200, 202, 197, 200, 202, 202, 201, 191, 200, 199, 203, 203, 201, 197, 205, 190, 208, 202, 201, 191, 202, 203, 198, 198, 198, 210, 198, 198, 197, 199, 199, 205, 202, 202, 192, 201, 202, 202, 202, 192, 204, 196, 203, 203, 194, 203, 202, 199, 196, 204, 203, 193, 203, 202, 203, 193, 204, 201, 197, 204, 193, 206, 195, 198, 204, 202, 199, 204, 194, 203, 201, 196, 11], "killed_ms": 21101, "recovery": {"steady_per_100ms": 199.2, "silent_100ms_steps": 0, "caught_up_ms": 200}, "applied": 80003, "listener": {"listener_packets": 75720, "listener_received": 37860, "listener_deliveries": 37860, "listener_woken": 10000, "listener_remembered": 10000, "listener_swept": 0, "listener_active": 10000}}
+{"devices": 10000, "period_ms": 5000, "memo": true, "restart_ms": 18.9, "steady_cpu_ms_per_s": 130.0, "senders_steady_cpu_ms_per_s": 90.0, "recovery_cpu_ms_first_10s": 1250.0, "senders_cpu_ms_first_10s": 1090.0, "generator": {"devices": 10000, "made": 80003, "stored": 80003, "sends": 80037, "again": 34, "stored_ms_p50": 0.572, "stored_ms_p99": 4.115, "stored_ms_max": 205.52, "longest_gap_ms": 0, "gap_from_ms": 0}, "timeline_100ms": [183, 208, 190, 212, 192, 191, 208, 208, 192, 191, 210, 208, 192, 192, 212, 193, 210, 193, 194, 214, 196, 196, 197, 200, 199, 210, 198, 198, 200, 196, 197, 196, 214, 195, 200, 199, 198, 196, 198, 214, 198, 197, 198, 198, 198, 198, 215, 199, 198, 198, 205, 199, 199, 209, 199, 201, 195, 200, 199, 199, 201, 200, 200, 201, 197, 203, 203, 194, 199, 204, 199, 196, 202, 200, 203, 200, 202, 200, 199, 200, 204, 200, 200, 200, 193, 201, 201, 202, 202, 191, 201, 200, 200, 202, 202, 202, 202, 200, 201, 200, 200, 196, 198, 199, 199, 198, 209, 198, 198, 200, 200, 199, 201, 201, 201, 198, 196, 207, 198, 200, 199, 204, 192, 204, 195, 202, 204, 203, 198, 198, 203, 202, 191, 202, 203, 200, 200, 202, 200, 202, 200, 200, 199, 191, 203, 199, 202, 202, 203, 193, 199, 208, 200, 199, 199, 199, 198, 198, 199, 199, 211, 198, 199, 197, 205, 197, 198, 199, 198, 200, 202, 200, 200, 200, 203, 200, 201, 199, 200, 201, 196, 205, 195, 205, 195, 204, 198, 196, 206, 196, 204, 195, 205, 194, 207, 194, 205, 196, 206, 195, 203, 201, 201, 191, 206, 201, 202, 192, 201, 203, 157, 210, 234, 199, 200, 199, 199, 200, 200, 202, 199, 199, 203, 200, 191, 201, 202, 201, 201, 202, 202, 195, 199, 204, 193, 203, 204, 204, 191, 209, 191, 204, 204, 190, 206, 205, 193, 205, 193, 206, 198, 197, 198, 209, 199, 198, 197, 197, 210, 199, 198, 203, 194, 205, 194, 204, 193, 205, 205, 195, 197, 205, 195, 205, 195, 206, 194, 195, 211, 193, 207, 193, 204, 202, 195, 203, 203, 194, 196, 204, 206, 195, 204, 202, 194, 204, 203, 193, 205, 193, 200, 200, 200, 200, 202, 202, 201, 201, 201, 201, 201, 201, 190, 201, 205, 198, 202, 201, 200, 202, 191, 202, 200, 205, 193, 202, 203, 203, 192, 207, 196, 205, 196, 198, 200, 207, 195, 197, 207, 196, 196, 208, 197, 197, 198, 209, 197, 195, 209, 198, 194, 201, 204, 196, 202, 204, 194, 204, 202, 193, 204, 203, 195, 205, 195, 205, 194, 205, 193, 207, 196, 204, 194, 205, 195, 207, 194, 206, 195, 195, 209, 194, 207, 195, 197, 208, 195, 195, 206, 196, 209, 196, 197, 196, 207, 196, 206, 194, 206, 195, 11], "killed_ms": 21102, "recovery": {"steady_per_100ms": 199.0, "silent_100ms_steps": 0, "caught_up_ms": 200}, "applied": 80003, "listener": {"listener_packets": 75724, "listener_received": 37862, "listener_deliveries": 37862, "listener_woken": 10000, "listener_remembered": 10000, "listener_swept": 0, "listener_active": 10000}}
+{"devices": 10000, "period_ms": 5000, "memo": true, "restart_ms": 13.4, "steady_cpu_ms_per_s": 110.0, "senders_steady_cpu_ms_per_s": 80.0, "recovery_cpu_ms_first_10s": 1080.0, "senders_cpu_ms_first_10s": 990.0, "generator": {"devices": 10000, "made": 80003, "stored": 80003, "sends": 80025, "again": 22, "stored_ms_p50": 0.542, "stored_ms_p99": 4.259, "stored_ms_max": 206.349, "longest_gap_ms": 0, "gap_from_ms": 0}, "timeline_100ms": [184, 207, 190, 208, 190, 207, 191, 210, 205, 186, 199, 210, 193, 211, 191, 211, 192, 192, 210, 192, 200, 212, 195, 195, 192, 207, 201, 197, 198, 215, 200, 197, 199, 197, 198, 199, 199, 199, 199, 198, 201, 199, 201, 203, 202, 201, 204, 201, 201, 202, 201, 198, 200, 199, 210, 192, 199, 201, 201, 202, 195, 210, 192, 198, 201, 202, 200, 200, 201, 201, 202, 201, 202, 191, 201, 203, 202, 194, 204, 195, 206, 194, 206, 196, 206, 196, 205, 195, 206, 194, 196, 207, 194, 208, 195, 197, 206, 200, 195, 203, 199, 200, 200, 200, 199, 198, 199, 201, 201, 201, 202, 199, 200, 201, 200, 199, 199, 203, 201, 202, 191, 204, 201, 202, 201, 201, 200, 198, 201, 201, 191, 206, 201, 202, 195, 201, 201, 200, 202, 201, 191, 201, 203, 201, 202, 203, 194, 199, 204, 195, 202, 200, 199, 202, 203, 202, 191, 201, 202, 202, 204, 191, 204, 203, 202, 191, 203, 202, 204, 193, 205, 191, 206, 194, 205, 203, 200, 194, 204, 195, 198, 201, 209, 202, 187, 210, 193, 201, 201, 200, 202, 203, 203, 199, 192, 202, 203, 202, 202, 191, 201, 208, 199, 198, 199, 198, 198, 200, 199, 199, 181, 204, 217, 205, 195, 200, 203, 203, 195, 205, 194, 197, 207, 195, 206, 195, 206, 194, 207, 194, 207, 198, 201, 190, 201, 203, 203, 202, 190, 203, 203, 202, 192, 204, 198, 206, 200, 197, 200, 198, 205, 204, 194, 204, 193, 205, 193, 205, 206, 198, 194, 201, 201, 201, 201, 200, 201, 201, 202, 189, 200, 205, 201, 202, 201, 201, 192, 200, 202, 202, 205, 191, 202, 201, 202, 202, 193, 202, 203, 202, 193, 201, 202, 202, 193, 207, 192, 206, 199, 199, 201, 198, 199, 209, 190, 206, 203, 199, 201, 200, 201, 200, 200, 200, 192, 200, 201, 200, 202, 202, 202, 200, 199, 201, 200, 199, 201, 200, 201, 201, 191, 202, 201, 199, 197, 202, 204, 201, 203, 191, 202, 202, 201, 201, 193, 208, 196, 204, 195, 196, 205, 201, 204, 191, 203, 201, 204, 202, 192, 203, 205, 192, 202, 202, 204, 191, 202, 200, 205, 193, 207, 190, 206, 196, 201, 201, 209, 199, 200, 198, 202, 190, 200, 202, 204, 201, 201, 200, 201, 193, 202, 202, 204, 191, 200, 202, 202, 202, 192, 203, 12], "killed_ms": 21101, "recovery": {"steady_per_100ms": 199.4, "silent_100ms_steps": 0, "caught_up_ms": 200}, "applied": 80003, "listener": {"listener_packets": 75714, "listener_received": 37857, "listener_deliveries": 37857, "listener_woken": 10000, "listener_remembered": 10000, "listener_swept": 0, "listener_active": 10000}}
+{"devices": 10000, "period_ms": 5000, "memo": false, "restart_ms": 19.8, "steady_cpu_ms_per_s": 140.0, "senders_steady_cpu_ms_per_s": 100.0, "recovery_cpu_ms_first_10s": 2730.0, "senders_cpu_ms_first_10s": 860.0, "generator": {"devices": 10000, "made": 80002, "stored": 80002, "sends": 80036, "again": 34, "stored_ms_p50": 0.574, "stored_ms_p99": 4.641, "stored_ms_max": 221.053, "longest_gap_ms": 0, "gap_from_ms": 0}, "timeline_100ms": [186, 194, 196, 214, 195, 195, 212, 196, 196, 197, 192, 203, 215, 196, 197, 196, 196, 213, 196, 198, 197, 199, 197, 200, 216, 197, 200, 198, 200, 199, 199, 203, 199, 202, 186, 203, 203, 201, 201, 200, 201, 203, 201, 200, 202, 184, 207, 205, 202, 201, 201, 200, 206, 197, 201, 201, 201, 199, 199, 200, 201, 201, 200, 201, 201, 201, 200, 199, 201, 199, 201, 191, 209, 192, 199, 202, 203, 194, 204, 204, 199, 193, 202, 203, 203, 191, 202, 201, 200, 199, 201, 202, 200, 204, 200, 192, 203, 206, 195, 206, 190, 202, 201, 203, 203, 190, 202, 202, 203, 202, 190, 206, 201, 203, 202, 191, 203, 205, 197, 194, 202, 205, 203, 193, 203, 204, 193, 204, 192, 208, 192, 207, 204, 196, 197, 206, 194, 205, 194, 205, 193, 209, 195, 196, 206, 196, 206, 195, 206, 194, 201, 202, 201, 203, 192, 201, 202, 201, 202, 194, 202, 203, 201, 192, 203, 203, 204, 191, 203, 204, 192, 205, 204, 195, 204, 196, 204, 194, 204, 194, 205, 195, 206, 195, 206, 194, 207, 195, 203, 196, 205, 196, 205, 197, 195, 206, 195, 207, 195, 207, 192, 202, 202, 201, 193, 203, 202, 202, 203, 191, 157, 205, 175, 260, 194, 211, 193, 194, 211, 197, 193, 196, 212, 195, 197, 212, 196, 194, 195, 215, 195, 197, 200, 196, 211, 186, 214, 181, 210, 200, 197, 198, 215, 182, 202, 202, 200, 198, 216, 199, 193, 192, 210, 192, 211, 192, 211, 192, 193, 211, 209, 202, 199, 190, 205, 203, 193, 204, 201, 197, 201, 204, 200, 200, 194, 201, 202, 201, 200, 197, 202, 203, 201, 201, 194, 201, 203, 192, 203, 203, 201, 203, 201, 192, 205, 201, 202, 200, 190, 202, 198, 200, 210, 198, 202, 199, 201, 198, 200, 195, 202, 196, 203, 200, 203, 200, 200, 198, 200, 202, 201, 197, 195, 204, 197, 208, 196, 204, 194, 196, 205, 205, 194, 206, 196, 197, 202, 205, 194, 206, 195, 206, 194, 205, 196, 197, 206, 194, 206, 195, 201, 202, 202, 201, 194, 200, 203, 202, 201, 191, 202, 203, 202, 202, 193, 203, 204, 192, 205, 204, 195, 202, 193, 204, 196, 204, 205, 193, 204, 195, 203, 198, 202, 206, 194, 206, 195, 196, 207, 194, 206, 195, 205, 195, 196, 206, 196, 203, 194, 204, 11], "killed_ms": 21102, "recovery": {"steady_per_100ms": 199.1, "silent_100ms_steps": 0, "caught_up_ms": 300}, "applied": 80002, "listener": {"listener_packets": 75710, "listener_received": 37855, "listener_deliveries": 37855, "listener_woken": 10000, "listener_remembered": 0, "listener_swept": 0, "listener_active": 10000}}
+{"devices": 10000, "period_ms": 5000, "memo": false, "restart_ms": 14.6, "steady_cpu_ms_per_s": 150.0, "senders_steady_cpu_ms_per_s": 100.0, "recovery_cpu_ms_first_10s": 2590.0, "senders_cpu_ms_first_10s": 830.0, "generator": {"devices": 10000, "made": 80011, "stored": 80011, "sends": 80045, "again": 34, "stored_ms_p50": 0.545, "stored_ms_p99": 4.54, "stored_ms_max": 222.224, "longest_gap_ms": 0, "gap_from_ms": 0}, "timeline_100ms": [184, 209, 191, 209, 197, 191, 211, 191, 210, 194, 195, 201, 193, 212, 196, 194, 213, 195, 196, 198, 196, 215, 189, 194, 207, 199, 199, 198, 199, 202, 200, 200, 199, 198, 199, 202, 201, 200, 200, 202, 200, 198, 205, 199, 199, 200, 199, 201, 200, 202, 214, 188, 206, 209, 199, 200, 195, 200, 202, 201, 199, 200, 201, 201, 200, 201, 198, 199, 200, 201, 200, 202, 192, 201, 203, 204, 193, 203, 204, 193, 207, 196, 205, 194, 204, 194, 207, 193, 205, 196, 206, 194, 206, 194, 207, 193, 206, 193, 206, 197, 204, 193, 201, 208, 189, 208, 200, 198, 201, 201, 199, 201, 192, 211, 198, 199, 200, 200, 199, 203, 200, 190, 201, 201, 201, 200, 205, 201, 201, 192, 201, 204, 193, 204, 202, 200, 200, 200, 201, 201, 201, 191, 200, 201, 201, 201, 202, 201, 202, 202, 199, 201, 192, 203, 202, 200, 194, 201, 204, 204, 191, 202, 204, 192, 203, 204, 204, 193, 205, 197, 204, 193, 207, 192, 205, 192, 208, 203, 192, 206, 192, 205, 204, 194, 206, 192, 207, 193, 204, 206, 194, 205, 196, 196, 206, 195, 208, 195, 196, 209, 187, 197, 207, 203, 205, 201, 190, 210, 190, 200, 156, 210, 231, 195, 210, 186, 202, 204, 193, 211, 204, 196, 196, 195, 210, 193, 211, 197, 195, 196, 202, 195, 212, 194, 195, 212, 195, 194, 214, 195, 197, 197, 195, 215, 196, 197, 205, 196, 197, 199, 209, 173, 212, 216, 189, 207, 191, 207, 192, 210, 208, 199, 201, 199, 197, 199, 199, 199, 201, 201, 203, 202, 199, 200, 198, 199, 201, 199, 200, 202, 202, 201, 200, 199, 199, 199, 200, 200, 200, 201, 195, 202, 203, 194, 206, 201, 203, 190, 201, 201, 202, 198, 199, 199, 203, 201, 203, 200, 193, 209, 195, 205, 191, 204, 201, 203, 193, 202, 203, 194, 204, 204, 202, 190, 202, 203, 203, 202, 199, 195, 196, 206, 204, 190, 201, 200, 202, 201, 200, 203, 203, 191, 201, 202, 203, 201, 193, 200, 201, 205, 200, 200, 198, 198, 198, 199, 200, 210, 199, 199, 201, 199, 199, 199, 199, 198, 199, 199, 209, 193, 202, 200, 200, 200, 200, 199, 201, 200, 198, 202, 202, 193, 204, 205, 193, 204, 194, 204, 204, 195, 200, 205, 194, 204, 195, 204, 193, 203, 206, 203, 12], "killed_ms": 21002, "recovery": {"steady_per_100ms": 199.8, "silent_100ms_steps": 0, "caught_up_ms": 300}, "applied": 80011, "listener": {"listener_packets": 75854, "listener_received": 37927, "listener_deliveries": 37927, "listener_woken": 10000, "listener_remembered": 0, "listener_swept": 0, "listener_active": 10000}}
+{"devices": 10000, "period_ms": 5000, "memo": false, "restart_ms": 13.6, "steady_cpu_ms_per_s": 110.0, "senders_steady_cpu_ms_per_s": 80.0, "recovery_cpu_ms_first_10s": 2740.0, "senders_cpu_ms_first_10s": 860.0, "generator": {"devices": 10000, "made": 80005, "stored": 80005, "sends": 80027, "again": 22, "stored_ms_p50": 0.57, "stored_ms_p99": 4.765, "stored_ms_max": 212.433, "longest_gap_ms": 0, "gap_from_ms": 0}, "timeline_100ms": [176, 203, 200, 203, 202, 201, 204, 190, 201, 197, 198, 215, 191, 205, 197, 200, 197, 207, 201, 196, 196, 197, 204, 204, 203, 197, 199, 184, 213, 202, 199, 200, 200, 202, 203, 194, 201, 201, 200, 200, 202, 200, 204, 185, 201, 205, 204, 202, 200, 200, 212, 200, 199, 197, 198, 206, 200, 197, 198, 197, 208, 196, 200, 201, 198, 201, 200, 199, 199, 202, 200, 199, 203, 193, 201, 208, 193, 205, 192, 205, 205, 192, 206, 196, 204, 199, 194, 205, 198, 201, 204, 193, 205, 196, 207, 200, 196, 194, 206, 196, 199, 200, 201, 203, 202, 204, 192, 203, 202, 202, 191, 201, 202, 204, 202, 200, 192, 201, 203, 201, 204, 191, 203, 206, 193, 203, 193, 205, 203, 194, 203, 205, 195, 205, 196, 195, 206, 193, 205, 205, 194, 205, 195, 203, 200, 194, 205, 194, 206, 195, 202, 201, 202, 203, 202, 189, 204, 201, 202, 203, 196, 196, 203, 205, 191, 201, 203, 202, 201, 196, 202, 203, 199, 195, 199, 199, 202, 200, 200, 203, 202, 200, 200, 202, 192, 200, 202, 200, 203, 194, 200, 203, 199, 201, 205, 201, 201, 191, 203, 201, 197, 198, 209, 198, 201, 199, 199, 199, 199, 199, 170, 209, 209, 205, 200, 194, 210, 193, 211, 193, 193, 210, 198, 189, 214, 195, 196, 196, 212, 195, 195, 202, 194, 214, 197, 196, 197, 203, 207, 197, 196, 197, 198, 215, 199, 196, 198, 198, 203, 203, 197, 192, 210, 192, 209, 192, 210, 192, 194, 209, 205, 200, 200, 199, 201, 201, 197, 202, 201, 204, 201, 199, 200, 201, 193, 204, 205, 193, 195, 202, 209, 195, 205, 194, 203, 200, 196, 207, 194, 206, 194, 206, 195, 206, 195, 194, 208, 194, 198, 206, 202, 203, 189, 201, 202, 201, 203, 204, 194, 205, 192, 204, 204, 192, 206, 203, 193, 205, 195, 205, 192, 204, 205, 193, 206, 193, 205, 195, 207, 195, 205, 194, 206, 195, 206, 194, 206, 197, 196, 198, 208, 194, 206, 192, 201, 200, 199, 207, 193, 205, 201, 197, 207, 196, 196, 200, 198, 210, 200, 200, 199, 198, 199, 198, 198, 200, 200, 200, 201, 201, 199, 200, 201, 197, 202, 204, 202, 200, 192, 202, 203, 201, 202, 196, 201, 204, 191, 204, 204, 200, 190, 202, 201, 201, 196, 209, 196, 195, 209, 197, 11], "killed_ms": 21101, "recovery": {"steady_per_100ms": 199.4, "silent_100ms_steps": 0, "caught_up_ms": 200}, "applied": 80005, "listener": {"listener_packets": 75742, "listener_received": 37871, "listener_deliveries": 37871, "listener_woken": 10000, "listener_remembered": 0, "listener_swept": 0, "listener_active": 10000}}
diff --git a/research/experiments/019-idle-links/2026-09-28-restart-links-sd-linux-aarch64.jsonl b/research/experiments/019-idle-links/2026-09-28-restart-links-sd-linux-aarch64.jsonl
new file mode 100644
index 0000000..7cbf031
--- /dev/null
+++ b/research/experiments/019-idle-links/2026-09-28-restart-links-sd-linux-aarch64.jsonl
@@ -0,0 +1 @@
+{"devices": 10000, "period_ms": 5000, "memo": true, "restart_ms": 28.6, "steady_cpu_ms_per_s": 100.0, "senders_steady_cpu_ms_per_s": 70.0, "recovery_cpu_ms_first_10s": 1060.0, "senders_cpu_ms_first_10s": 940.0, "generator": {"devices": 10000, "made": 80007, "stored": 80007, "sends": 80276, "again": 269, "stored_ms_p50": 4.092, "stored_ms_p99": 110.889, "stored_ms_max": 267.41, "longest_gap_ms": 200, "gap_from_ms": 17700}, "timeline_100ms": [186, 197, 198, 206, 192, 190, 221, 181, 167, 239, 211, 202, 161, 136, 296, 203, 200, 187, 203, 201, 200, 194, 214, 205, 193, 128, 240, 238, 196, 197, 199, 197, 205, 195, 206, 198, 209, 193, 195, 207, 197, 196, 203, 198, 207, 163, 120, 306, 203, 157, 249, 198, 200, 212, 194, 187, 187, 213, 212, 162, 221, 212, 196, 214, 192, 202, 200, 194, 190, 222, 149, 249, 197, 199, 190, 214, 202, 195, 200, 36, 356, 187, 223, 161, 231, 196, 202, 202, 208, 194, 207, 197, 195, 199, 189, 216, 155, 252, 191, 197, 192, 207, 203, 196, 212, 196, 188, 208, 144, 246, 216, 195, 190, 207, 211, 191, 200, 208, 195, 206, 176, 213, 204, 202, 195, 190, 186, 201, 228, 192, 202, 203, 201, 206, 180, 215, 195, 193, 208, 204, 201, 192, 191, 176, 234, 214, 197, 196, 191, 173, 234, 180, 224, 192, 200, 213, 189, 205, 186, 212, 193, 213, 194, 199, 203, 191, 199, 185, 171, 240, 196, 215, 134, 264, 199, 176, 210, 0, 0, 619, 194, 200, 200, 113, 269, 217, 95, 298, 201, 216, 191, 203, 164, 236, 204, 200, 200, 194, 201, 199, 202, 201, 202, 197, 200, 200, 206, 163, 136, 301, 135, 180, 188, 291, 158, 232, 211, 205, 200, 190, 205, 193, 210, 202, 201, 199, 166, 229, 200, 201, 197, 207, 195, 196, 170, 235, 138, 255, 202, 186, 222, 201, 176, 16, 398, 206, 208, 163, 224, 198, 201, 205, 131, 261, 198, 198, 218, 192, 151, 235, 201, 210, 100, 308, 198, 192, 214, 189, 197, 185, 217, 206, 191, 214, 188, 184, 215, 211, 188, 68, 0, 541, 190, 214, 193, 123, 154, 299, 231, 152, 44, 0, 591, 216, 187, 192, 208, 203, 208, 202, 173, 218, 205, 198, 200, 132, 269, 194, 204, 198, 156, 249, 190, 197, 186, 209, 212, 204, 203, 198, 192, 206, 195, 207, 186, 207, 205, 199, 206, 202, 192, 194, 169, 141, 293, 199, 198, 199, 207, 200, 203, 187, 204, 212, 201, 191, 202, 198, 198, 196, 205, 199, 202, 199, 196, 210, 185, 213, 198, 194, 200, 210, 204, 194, 173, 230, 195, 193, 207, 201, 84, 325, 194, 196, 175, 224, 201, 185, 213, 170, 232, 192, 178, 231, 152, 243, 189, 176, 242, 184, 203, 210, 205, 201, 181, 218, 193, 210, 195, 102, 117], "killed_ms": 21102, "recovery": {"steady_per_100ms": 198.9, "silent_100ms_steps": 2, "caught_up_ms": 300}, "applied": 80007, "listener": {"listener_packets": 75909, "listener_received": 38028, "listener_deliveries": 37881, "listener_woken": 10000, "listener_remembered": 10000, "listener_swept": 0, "listener_active": 10000}}
diff --git a/research/experiments/019-idle-links/2026-09-28-restart-quinn-linux-aarch64.jsonl b/research/experiments/019-idle-links/2026-09-28-restart-quinn-linux-aarch64.jsonl
new file mode 100644
index 0000000..839d380
--- /dev/null
+++ b/research/experiments/019-idle-links/2026-09-28-restart-quinn-linux-aarch64.jsonl
@@ -0,0 +1,3 @@
+{"clients": 10000, "period_ms": 5000, "restart_ms": 1.6, "killed_at_s": 26.0, "steady_cpu_ms_per_s": 120.0, "senders_steady_cpu_ms_per_s": 120.0, "recovery_cpu_ms_first_10s": 8290.0, "senders_cpu_ms_first_10s": 9140.0, "client": {"clients": 10000, "made": 89998, "stored": 89998, "again": 10173, "stored_ms_p50": 0.148, "stored_ms_p99": 5826.357, "stored_ms_max": 11517.095, "longest_gap_ms": 900, "gap_from_ms": 26100}, "timeline_100ms": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 95, 89, 73, 196, 347, 236, 222, 201, 206, 156, 138, 138, 137, 210, 293, 248, 166, 183, 200, 198, 192, 124, 128, 135, 191, 291, 230, 218, 208, 108, 131, 157, 110, 161, 239, 292, 184, 160, 208, 201, 198, 180, 131, 97, 95, 121, 190, 157, 148, 156, 291, 281, 220, 179, 234, 224, 379, 465, 272, 215, 202, 206, 221, 344, 247, 293, 223, 209, 200, 200, 198, 202, 198, 200, 202, 201, 219, 200, 200, 201, 199, 200, 200, 200, 200, 200, 200, 200, 200, 198, 202, 198, 202, 200, 200, 200, 201, 197, 202, 200, 200, 200, 200, 200, 200, 202, 198, 200, 200, 200, 200, 200, 200, 200, 200, 200, 198, 202, 200, 200, 200, 200, 200, 198, 202, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 198, 203, 199, 200, 200, 200, 200, 200, 200, 200, 200, 198, 202, 200, 200, 201, 199, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 198, 203, 199, 200, 200, 200, 200, 200, 198, 202, 200, 201, 199, 200, 200, 200, 200, 202, 198, 202, 198, 200, 200, 200, 200, 200, 200, 200, 198, 204, 198, 200, 200, 200, 200, 200, 200, 200, 200, 198, 202, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 198, 202, 200, 200, 201, 199, 200, 200, 200, 200, 200, 200, 198, 200, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 38, 10, 78, 93, 166, 136, 137, 126, 128, 128, 116, 65, 54, 66, 100, 126, 116, 105, 122, 109, 108, 69, 48, 95, 95, 92, 101, 114, 102, 109, 101, 101, 78, 71, 54, 132, 101, 112, 102, 98, 284, 223, 111, 94, 220, 326, 387, 348, 335, 380, 336, 322, 195, 114, 188, 241, 296, 306, 409, 342, 346, 299, 273, 163, 189, 237, 321, 324, 266, 370, 351, 334, 287, 151, 180, 233, 298, 288, 316, 339, 354, 331, 307, 202, 86, 154, 195, 211, 279, 309, 483, 465, 462, 435, 243, 191, 173, 268, 324, 485, 631, 310, 264, 203, 221, 379, 301, 277, 276, 229, 216, 208, 202, 200, 200, 200, 198, 202, 200, 200, 200, 200, 200, 200, 200, 200, 198, 202, 200, 200, 200, 200, 200, 200, 200, 200, 200, 202, 198, 200, 200, 200, 200, 200, 198, 202, 200, 200, 200, 200, 200, 200, 200, 200, 200, 198, 202, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 199, 201, 200, 200, 200, 194, 206, 199, 201, 200, 200, 200, 200, 200, 200, 200, 198, 202, 200, 200, 200, 200, 199, 199, 202, 198, 202, 200, 200, 200, 200, 200, 200], "killed_ms": 26010, "recovery": {"steady_per_100ms": 200.0, "silent_100ms_steps": 9, "caught_up_ms": 11700}}
+{"clients": 10000, "period_ms": 5000, "restart_ms": 1.3, "killed_at_s": 26.0, "steady_cpu_ms_per_s": 90.0, "senders_steady_cpu_ms_per_s": 90.0, "recovery_cpu_ms_first_10s": 8300.0, "senders_cpu_ms_first_10s": 9120.0, "client": {"clients": 10000, "made": 89998, "stored": 89998, "again": 10069, "stored_ms_p50": 0.136, "stored_ms_p99": 5348.757, "stored_ms_max": 11197.085, "longest_gap_ms": 1000, "gap_from_ms": 26000}, "timeline_100ms": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 113, 74, 133, 239, 244, 237, 209, 204, 178, 174, 148, 124, 156, 197, 298, 235, 193, 193, 192, 201, 195, 109, 116, 109, 206, 267, 279, 233, 205, 113, 156, 112, 127, 235, 199, 237, 180, 187, 206, 205, 202, 147, 81, 64, 128, 183, 136, 176, 158, 150, 297, 262, 167, 230, 268, 284, 477, 383, 239, 220, 205, 200, 228, 284, 279, 292, 238, 201, 202, 199, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 198, 200, 202, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 198, 202, 198, 202, 200, 200, 200, 200, 200, 200, 200, 200, 201, 199, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 198, 202, 200, 200, 200, 200, 198, 202, 200, 200, 200, 198, 202, 198, 202, 200, 200, 201, 199, 200, 200, 200, 200, 200, 200, 200, 200, 198, 202, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 198, 203, 199, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 201, 199, 200, 198, 202, 200, 200, 200, 200, 201, 199, 200, 200, 200, 200, 200, 200, 201, 199, 200, 199, 201, 200, 200, 198, 202, 198, 202, 200, 198, 202, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 70, 45, 85, 117, 135, 134, 135, 128, 128, 122, 81, 74, 92, 98, 99, 111, 117, 109, 116, 118, 90, 44, 71, 76, 105, 101, 102, 102, 112, 121, 110, 81, 49, 85, 81, 114, 103, 95, 82, 257, 270, 214, 136, 278, 279, 275, 372, 316, 379, 339, 337, 292, 165, 255, 184, 251, 348, 364, 348, 348, 337, 316, 211, 173, 262, 226, 329, 364, 309, 362, 356, 335, 301, 152, 211, 241, 208, 365, 392, 352, 286, 320, 340, 271, 221, 194, 199, 208, 248, 456, 485, 454, 431, 376, 421, 434, 306, 271, 266, 220, 216, 200, 200, 219, 235, 206, 204, 202, 200, 200, 200, 200, 200, 200, 200, 200, 198, 202, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 198, 203, 199, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 202, 198, 200, 200, 200, 200, 198, 200, 202, 198, 202, 200, 200, 200, 198, 202, 200, 200, 200, 198, 202, 200, 200, 200, 200, 200, 198, 202, 200, 200, 199, 201, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 198, 202, 200, 200, 198, 202, 198, 2], "killed_ms": 26008, "recovery": {"steady_per_100ms": 200.0, "silent_100ms_steps": 10, "caught_up_ms": 10900}}
+{"clients": 10000, "period_ms": 5000, "restart_ms": 1.5, "killed_at_s": 26.0, "steady_cpu_ms_per_s": 120.0, "senders_steady_cpu_ms_per_s": 110.0, "recovery_cpu_ms_first_10s": 8420.0, "senders_cpu_ms_first_10s": 9150.0, "client": {"clients": 10000, "made": 89998, "stored": 89998, "again": 10115, "stored_ms_p50": 0.148, "stored_ms_p99": 5270.907, "stored_ms_max": 10060.656, "longest_gap_ms": 1000, "gap_from_ms": 26000}, "timeline_100ms": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 112, 50, 109, 169, 287, 285, 223, 210, 207, 162, 124, 120, 78, 292, 232, 288, 205, 196, 194, 196, 193, 139, 98, 131, 184, 292, 258, 212, 199, 109, 150, 148, 98, 135, 200, 279, 207, 208, 229, 203, 200, 179, 116, 71, 74, 123, 141, 158, 159, 156, 319, 264, 226, 209, 315, 218, 359, 387, 353, 195, 218, 205, 221, 295, 318, 243, 240, 222, 203, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 199, 201, 200, 200, 200, 200, 200, 200, 200, 198, 200, 202, 200, 200, 200, 200, 200, 200, 200, 199, 201, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 198, 202, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 198, 200, 202, 200, 200, 200, 200, 200, 198, 202, 200, 198, 202, 200, 199, 201, 200, 200, 198, 202, 200, 200, 200, 198, 202, 200, 200, 200, 199, 201, 200, 200, 200, 200, 200, 200, 200, 200, 200, 199, 201, 200, 200, 200, 200, 200, 200, 200, 199, 201, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 198, 202, 200, 200, 198, 202, 200, 200, 200, 200, 200, 200, 200, 200, 200, 198, 202, 200, 200, 200, 200, 200, 200, 198, 202, 200, 198, 202, 200, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 29, 45, 110, 144, 154, 142, 127, 140, 130, 110, 70, 57, 98, 131, 113, 109, 116, 118, 125, 123, 75, 64, 94, 97, 117, 102, 110, 101, 112, 117, 104, 49, 23, 88, 93, 129, 120, 108, 118, 269, 245, 192, 142, 216, 312, 347, 302, 361, 386, 344, 313, 250, 170, 200, 227, 333, 354, 409, 320, 331, 332, 309, 185, 238, 232, 278, 337, 350, 343, 323, 338, 341, 236, 242, 232, 215, 244, 414, 363, 313, 284, 303, 290, 236, 287, 209, 288, 309, 308, 462, 449, 452, 462, 329, 281, 305, 306, 260, 226, 222, 200, 200, 200, 200, 200, 201, 199, 200, 200, 198, 202, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 198, 202, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 198, 202, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 198, 201, 199, 202, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 198, 202, 200, 178, 220, 202, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 198, 202, 200, 200], "killed_ms": 26011, "recovery": {"steady_per_100ms": 200.0, "silent_100ms_steps": 10, "caught_up_ms": 10700}}
diff --git a/research/experiments/019-idle-links/quic/src/main.rs b/research/experiments/019-idle-links/quic/src/main.rs
index cdb0376..28cda77 100644
--- a/research/experiments/019-idle-links/quic/src/main.rs
+++ b/research/experiments/019-idle-links/quic/src/main.rs
@@ -20,6 +20,10 @@
//! connects again and sends it again. Then a line of JSON: orders made
//! and stored, connections made again, the orders stored in each
//! 100 ms, the time from an order made to it stored
+//! idle-quic drain PORT COUNT SIZE
+//! one client sends COUNT messages of SIZE bytes over one stream
+//! (each after its u32 length) and waits until the service says it
+//! read them all; a line of JSON: how long
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
@@ -72,7 +76,17 @@ fn serve(cert: Vec<u8>, key: Vec<u8>, port: u16) {
let Ok(connection) = incoming.await else { return };
kept.lock().unwrap().push(connection.clone());
while let Ok((mut send, mut recv)) = connection.accept_bi().await {
- let Ok(bytes) = recv.read_to_end(1024).await else { return };
+ let mut first = [0u8; 4];
+ if recv.read_exact(&mut first).await.is_err() {
+ return;
+ }
+ if first == *DRAIN {
+ drain(&mut send, &mut recv).await;
+ continue;
+ }
+ let Ok(rest) = recv.read_to_end(1024).await else { return };
+ let mut bytes = first.to_vec();
+ bytes.extend_from_slice(&rest);
if bytes.len() < 16 {
return;
}
@@ -99,6 +113,32 @@ fn serve(cert: Vec<u8>, key: Vec<u8>, port: u16) {
});
}
+/// A drain's stream begins with these bytes; an order's never does (a client number that large).
+const DRAIN: &[u8; 4] = b"pure";
+
+/// Messages each after its u32 length until a length of 0; then how many, as the answer.
+async fn drain(send: &mut quinn::SendStream, recv: &mut quinn::RecvStream) {
+ let mut count = 0u64;
+ let mut buffer = Vec::new();
+ loop {
+ let mut length = [0u8; 4];
+ if recv.read_exact(&mut length).await.is_err() {
+ return;
+ }
+ let length = u32::from_le_bytes(length) as usize;
+ if length == 0 {
+ break;
+ }
+ buffer.resize(length, 0);
+ if recv.read_exact(&mut buffer).await.is_err() {
+ return;
+ }
+ count += 1;
+ }
+ let _ = send.write_all(&count.to_le_bytes()).await;
+ let _ = send.finish();
+}
+
#[derive(Debug)]
struct Anyone; // the test's own service: its certificate is not checked
@@ -308,12 +348,39 @@ fn steady(port: u16, first: u64, count: u64, period: Duration, seconds: u64, win
});
}
+/// One client, COUNT messages of SIZE bytes over one stream, and the answer.
+fn drain_client(port: u16, count: u64, size: usize) {
+ let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
+ runtime.block_on(async move {
+ let mut endpoint = Endpoint::client(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
+ endpoint.set_default_client_config(client_config());
+ let connection = endpoint.connect(SocketAddr::from(([127, 0, 0, 1], port)), "localhost").unwrap().await.unwrap();
+ let (mut send, mut recv) = connection.open_bi().await.unwrap();
+ let message = vec![b'x'; size];
+ let began = Instant::now();
+ send.write_all(DRAIN).await.unwrap();
+ for _ in 0..count {
+ send.write_all(&(size as u32).to_le_bytes()).await.unwrap();
+ send.write_all(&message).await.unwrap();
+ }
+ send.write_all(&0u32.to_le_bytes()).await.unwrap();
+ send.finish().unwrap();
+ let answer = recv.read_to_end(16).await.unwrap();
+ let seconds = began.elapsed().as_secs_f64();
+ let read = u64::from_le_bytes(answer[..8].try_into().unwrap());
+ println!("{{\"messages\":{count},\"size\":{size},\"read\":{read},\"seconds\":{seconds:.3},\"per_second\":{:.0}}}",
+ count as f64 / seconds);
+ });
+}
+
fn main() {
let args: Vec<String> = std::env::args().collect();
match args.get(1).map(String::as_str) {
Some("serve") if args.len() == 4 || args.len() == 5 => serve(std::fs::read(&args[2]).unwrap(),
std::fs::read(&args[3]).unwrap(),
args.get(4).map_or(0, |p| p.parse().unwrap())),
+ Some("drain") if args.len() == 5 => drain_client(args[2].parse().unwrap(), args[3].parse().unwrap(),
+ args[4].parse().unwrap()),
Some("steady") if args.len() == 8 => steady(args[2].parse().unwrap(), args[3].parse().unwrap(),
args[4].parse().unwrap(),
Duration::from_millis(args[5].parse().unwrap()),
@@ -322,7 +389,7 @@ fn main() {
args[4].parse().unwrap(), args[5].parse().unwrap()),
_ => {
eprintln!("idle-quic serve CERT.der KEY.der [PORT] | idle-quic connect PORT FIRST COUNT WINDOW | \
- idle-quic steady PORT FIRST COUNT PERIOD_MS SECONDS WINDOW");
+ idle-quic steady PORT FIRST COUNT PERIOD_MS SECONDS WINDOW | idle-quic drain PORT COUNT SIZE");
std::process::exit(2);
}
}
diff --git a/research/experiments/019-idle-links/run.py b/research/experiments/019-idle-links/run.py
index 1a179e8..63f88a2 100644
--- a/research/experiments/019-idle-links/run.py
+++ b/research/experiments/019-idle-links/run.py
@@ -16,8 +16,22 @@ clients connect, send one order each and wait until it is stored, and stay
connected at rest; the service's memory is read then; then each sends one
more order over its connection.
+With --restart (M2), n devices each make an order every --period-ms and
+send it until it is stored; at --kill-at seconds the receiver is killed
+(-9) and started again at once: the orders stored each 100 ms, the CPU of
+both sides before and after, the X25519 made (none when the node
+remembers its secrets; --no-memo forgets them), and that every order made
+was applied once. With --quic too, the same for quinn clients, which
+connect again when their connection is lost.
+
+With --first-contact DELAYS (M4), for each one-way delay in ms, in a
+network namespace of its own (unshare -rn) whose loopback delays UDP:
+senders the node never heard from, and new quinn clients, each send one
+order and wait until it is stored, one at a time.
+
python3 research/experiments/019-idle-links/run.py [--counts 1000,10000]
- [--store DIRECTORY] [--out FILE.jsonl] [--quic]
+ [--store DIRECTORY] [--out FILE.jsonl] [--quic] [--restart [--no-memo]]
+ [--first-contact 5,25,50]
"""
import argparse
import json
@@ -197,21 +211,21 @@ def quic(counts, work, window, out):
file.write(line + '\n')
-def recovery(timeline, gap_from_ms, gap_ms):
- """From a timeline of orders stored each 100 ms: the steady rate before
- the gap, and how long after the gap began until a second of storing was
- back at 90 % of it with what waited cleared (the rate seen at least once
- and the backlog gone: the cumulative stored caught up with the steady
- line)."""
- start = gap_from_ms // 100
+def recovery(timeline, killed_ms):
+ """From a timeline of orders stored each 100 ms and when the receiver was
+ killed: the steady rate in the 5 s before; the 100 ms steps after the
+ kill with nothing stored; and how long until the orders stored since the
+ kill caught up with the steady rate (99 %), what waited included."""
+ start = killed_ms // 100
before = timeline[max(0, start - 50):start]
steady = sum(before) / len(before) if before else 0
- total_before = sum(timeline[:start])
- for b in range(start + gap_ms // 100, len(timeline)):
- expected = total_before + steady * (b + 1 - start)
- if sum(timeline[:b + 1]) >= expected * 0.99:
- return {'steady_per_100ms': round(steady, 1), 'caught_up_ms': (b + 1 - start) * 100}
- return {'steady_per_100ms': round(steady, 1), 'caught_up_ms': None}
+ silent = sum(1 for b in range(start, min(len(timeline), start + 100)) if not timeline[b])
+ since = 0
+ for b in range(start, len(timeline)):
+ since += timeline[b]
+ if b > start and since >= steady * (b + 1 - start) * 0.99:
+ return {'steady_per_100ms': round(steady, 1), 'silent_100ms_steps': silent, 'caught_up_ms': (b + 1 - start) * 100}
+ return {'steady_per_100ms': round(steady, 1), 'silent_100ms_steps': silent, 'caught_up_ms': None}
def restart_pure(n, work, keys, module, period_ms, seconds, kill_at, memo):
@@ -231,27 +245,33 @@ def restart_pure(n, work, keys, module, period_ms, seconds, kill_at, memo):
link_port, info['public_key'], 0, n, period_ms, seconds]],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
for line in generator.stderr: # progress, a line a second
- if json.loads(line).get('t', 0) >= kill_at:
+ t = json.loads(line).get('t', 0)
+ if t >= kill_at:
break
- cpu_before = cpu_ms(node.pid)
+ seen = time.monotonic()
+ cpu_before, senders_before = cpu_ms(node.pid), cpu_ms(generator.pid)
time.sleep(1.0)
steady_cpu = cpu_ms(node.pid) - cpu_before # ms of CPU in the second before the kill
+ senders_steady = cpu_ms(generator.pid) - senders_before
node.kill()
node.wait()
killed = time.monotonic()
+ killed_ms = int((t + killed - seen) * 1000) # on the generator's clock
if not memo:
(work / 'node.key.links').unlink()
node, _ = start(args)
row = {'devices': n, 'period_ms': period_ms, 'memo': memo, 'restart_ms': round((time.monotonic() - killed) * 1000, 1),
- 'steady_cpu_ms_per_s': round(steady_cpu, 1)}
- cpu_start = cpu_ms(node.pid)
+ 'steady_cpu_ms_per_s': round(steady_cpu, 1), 'senders_steady_cpu_ms_per_s': round(senders_steady, 1)}
+ cpu_start, senders_start = cpu_ms(node.pid), cpu_ms(generator.pid)
time.sleep(10)
row['recovery_cpu_ms_first_10s'] = round(cpu_ms(node.pid) - cpu_start, 1)
+ row['senders_cpu_ms_first_10s'] = round(cpu_ms(generator.pid) - senders_start, 1)
out, error = generator.communicate(timeout=600)
result = json.loads(out)
row['generator'] = {k: v for k, v in result.items() if k != 'timeline_100ms'}
row['timeline_100ms'] = result['timeline_100ms']
- row['recovery'] = recovery(result['timeline_100ms'], result['gap_from_ms'], result['longest_gap_ms'])
+ row['killed_ms'] = killed_ms
+ row['recovery'] = recovery(result['timeline_100ms'], killed_ms)
count = view(port, 'count')
row['applied'] = count
assert count == result['made'] == result['stored'], (count, result['made'], result['stored'])
@@ -273,28 +293,97 @@ def restart_quic(n, work, period_ms, seconds, kill_at):
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
began = time.monotonic()
time.sleep(kill_at)
- cpu_before = cpu_ms(server.pid)
+ cpu_before, clients_before = cpu_ms(server.pid), cpu_ms(client.pid)
time.sleep(1.0)
steady_cpu = cpu_ms(server.pid) - cpu_before
+ clients_steady = cpu_ms(client.pid) - clients_before
server.kill()
server.wait()
killed = time.monotonic()
server, _ = start(args)
row = {'clients': n, 'period_ms': period_ms, 'restart_ms': round((time.monotonic() - killed) * 1000, 1),
- 'killed_at_s': round(killed - began, 1), 'steady_cpu_ms_per_s': round(steady_cpu, 1)}
- cpu_start = cpu_ms(server.pid)
+ 'killed_at_s': round(killed - began, 1), 'steady_cpu_ms_per_s': round(steady_cpu, 1),
+ 'senders_steady_cpu_ms_per_s': round(clients_steady, 1)}
+ cpu_start, clients_start = cpu_ms(server.pid), cpu_ms(client.pid)
time.sleep(10)
row['recovery_cpu_ms_first_10s'] = round(cpu_ms(server.pid) - cpu_start, 1)
+ row['senders_cpu_ms_first_10s'] = round(cpu_ms(client.pid) - clients_start, 1)
out, _ = client.communicate(timeout=900)
result = json.loads(out)
row['client'] = {k: v for k, v in result.items() if k != 'timeline_100ms'}
row['timeline_100ms'] = result['timeline_100ms']
- row['recovery'] = recovery(result['timeline_100ms'], result['gap_from_ms'], result['longest_gap_ms'])
+ row['killed_ms'] = int((killed - began) * 1000)
+ row['recovery'] = recovery(result['timeline_100ms'], row['killed_ms'])
server.kill()
server.wait()
return row
+def first_contact(work, delay_ms, count):
+ """M4, run in a network namespace of its own whose loopback delays UDP
+ by delay_ms each way: count senders the node never heard from each send
+ one order and wait until it is stored, one at a time; then count quinn
+ clients each connect, send one and wait. How many round trips each
+ takes is the time over 2 delay_ms."""
+ source = work / 'receiver.aup'
+ source.write_text(check.LINK_RECEIVER)
+ module = work / 'receiver.pmod'
+ check.compile_module(Path(os.environ.get('AUP', ROOT.parent / 'aup/build/aup')), source, module)
+ keys = work / 'keys'
+ subprocess.run([str(ROOT / 'build/links'), 'keys', str(count), str(keys)], check=True)
+ port, link_port = free_port(socket.SOCK_STREAM), free_port(socket.SOCK_DGRAM)
+ node, info = start([ROOT / 'build/pure-serve', '--module', module, '--store', work / 'store', '--port', port,
+ '--key', work / 'node.key', '--link-port', link_port, '--senders', f'{keys}.senders'])
+ sent = subprocess.run([str(a) for a in [ROOT / 'build/links', 'send', keys, module, 'order', '127.0.0.1', link_port,
+ info['public_key'], 0, count, 1, 1]], capture_output=True, text=True)
+ node.terminate()
+ node.communicate(timeout=60)
+ pure = json.loads(sent.stdout)
+ make_certificate(work)
+ binary = Path(__file__).parent / 'quic/target/release/idle-quic'
+ server, info = start([binary, 'serve', work / 'cert.der', work / 'key.der'])
+ fresh = subprocess.run([str(binary), 'connect', str(info['port']), '0', str(count), '1'], input=b'',
+ capture_output=True, timeout=600)
+ server.kill()
+ server.wait()
+ quic = json.loads(fresh.stdout.decode().splitlines()[0])
+ rtt = 2 * delay_ms
+ return {'delay_ms_each_way': delay_ms, 'senders': count,
+ 'pure_ms_p50': pure['stored_ms_p50'], 'pure_ms_p99': pure['stored_ms_p99'],
+ 'pure_round_trips': round(pure['stored_ms_p50'] / rtt, 2),
+ 'quinn_ms_p50': quic['stored_ms_p50'], 'quinn_ms_p99': quic['stored_ms_p99'],
+ 'quinn_round_trips': round(quic['stored_ms_p50'] / rtt, 2)}
+
+
+def in_namespace(delay_ms, count, out):
+ """first_contact in `unshare -rn`, UDP alone through netem (the node's
+ HTTP view stays quick)."""
+ import shutil
+ tc = shutil.which('tc') or '/usr/sbin/tc'
+ ip = shutil.which('ip') or '/usr/sbin/ip'
+ script = f"""
+import json, subprocess, sys, tempfile
+from pathlib import Path
+sys.path.insert(0, {str(Path(__file__).parent)!r})
+import run
+subprocess.run([{ip!r}, 'link', 'set', 'lo', 'up'], check=True)
+for rule in (['qdisc', 'add', 'dev', 'lo', 'root', 'handle', '1:', 'prio'],
+ ['qdisc', 'add', 'dev', 'lo', 'parent', '1:3', 'handle', '30:', 'netem', 'delay', '{delay_ms}ms'],
+ ['filter', 'add', 'dev', 'lo', 'protocol', 'ip', 'parent', '1:0', 'prio', '3', 'u32', 'match', 'ip',
+ 'protocol', '17', '0xff', 'flowid', '1:3']):
+ subprocess.run([{tc!r}] + rule, check=True)
+with tempfile.TemporaryDirectory(prefix='pure-m4-') as d:
+ print(json.dumps(run.first_contact(Path(d), {delay_ms}, {count})))
+"""
+ done = subprocess.run(['unshare', '-rn', sys.executable, '-c', script], capture_output=True, text=True, timeout=1800)
+ assert done.returncode == 0, done.stderr[-3000:]
+ line = done.stdout.strip().splitlines()[-1]
+ print(line, flush=True)
+ if out:
+ with open(out, 'a') as file:
+ file.write(line + '\n')
+
+
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--counts', default='1000,10000,100000,1000000')
@@ -308,8 +397,15 @@ def main():
parser.add_argument('--period-ms', type=int, default=5000)
parser.add_argument('--seconds', type=int, default=40)
parser.add_argument('--kill-at', type=int, default=20)
+ parser.add_argument('--first-contact', type=str, default=None,
+ help='M4: one-way delays in ms (for example 5,25,50), each in a namespace of its own')
args = parser.parse_args()
counts = [int(c) for c in args.counts.split(',')]
+ if args.first_contact:
+ subprocess.run(['cargo', 'build', '--release', '--offline', '--quiet'], cwd=Path(__file__).parent / 'quic', check=True)
+ for delay in args.first_contact.split(','):
+ in_namespace(int(delay), counts[0], args.out)
+ return
if args.restart:
with tempfile.TemporaryDirectory(prefix='pure-m2-', dir=args.store) as directory:
work = Path(directory)