@7h3/protocol 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (294) hide show
  1. package/README.md +6 -1317
  2. package/agentAdapter.d.ts +26 -0
  3. package/auditLog.d.ts +54 -0
  4. package/capability.d.ts +67 -0
  5. package/cborCodec.d.ts +34 -0
  6. package/conformanceVectors.d.ts +20 -0
  7. package/encryption.d.ts +85 -0
  8. package/envelopeCbor.d.ts +33 -0
  9. package/frameworkAdapters.d.ts +72 -0
  10. package/gateway.d.ts +53 -0
  11. package/grpcBinding.d.ts +29 -0
  12. package/httpBinding.d.ts +50 -0
  13. package/index.d.ts +39 -0
  14. package/index.js +3670 -0
  15. package/keyInfra.d.ts +64 -0
  16. package/keyRegistry.d.ts +9 -0
  17. package/keyRotation.d.ts +20 -0
  18. package/mcpGateway.d.ts +37 -0
  19. package/mcpTransports.d.ts +62 -0
  20. package/mcpWrapper.d.ts +83 -0
  21. package/otel.d.ts +65 -0
  22. package/package.json +13 -121
  23. package/policyEnforcer.d.ts +50 -0
  24. package/policyTelemetryFeedback.d.ts +11 -0
  25. package/{src/protocol.d.ts → protocol.d.ts} +6 -2
  26. package/protocolAgent.d.ts +58 -0
  27. package/protocolBinary.d.ts +8 -0
  28. package/protocolCapabilities.d.ts +24 -0
  29. package/protocolReplay.d.ts +35 -0
  30. package/protocolTransport.d.ts +73 -0
  31. package/queueBinding.d.ts +43 -0
  32. package/rateLimiter.d.ts +18 -0
  33. package/{src/redisClient.ts → redisClient.d.ts} +25 -75
  34. package/replayStores.d.ts +98 -0
  35. package/revocation.d.ts +71 -0
  36. package/routePolicy.d.ts +30 -0
  37. package/runtimePolicy.d.ts +24 -0
  38. package/runtimePolicyManager.d.ts +15 -0
  39. package/runtimePolicyPresets.d.ts +11 -0
  40. package/signedResponse.d.ts +21 -0
  41. package/stream.d.ts +59 -0
  42. package/telemetry.d.ts +79 -0
  43. package/webhookBinding.d.ts +30 -0
  44. package/wsBinding.d.ts +52 -0
  45. package/.dockerignore +0 -19
  46. package/.github/dependabot.yml +0 -32
  47. package/.github/workflows/ci.yml +0 -31
  48. package/.github/workflows/publish.yml +0 -59
  49. package/.github/workflows/scorecard.yml +0 -37
  50. package/7h3.example.yaml +0 -125
  51. package/CHANGELOG.md +0 -152
  52. package/CONTRIBUTING.md +0 -82
  53. package/Dockerfile +0 -73
  54. package/GOVERNANCE.md +0 -62
  55. package/SECURITY.md +0 -70
  56. package/bench-results/replay-cache-full-1777891033256.json +0 -10
  57. package/bench-results/replay-cache-full-1777896317488.json +0 -10
  58. package/bench-results/replay-cache-full-1777900993184.json +0 -10
  59. package/bench-results/replay-cache-full-1777901019285.json +0 -10
  60. package/bench-results/replay-cache-quick-1777870170126.json +0 -10
  61. package/bench-results/signature-profiles-quick-1775875160079.json +0 -85
  62. package/bench-results/signature-profiles-quick-1775983539716.json +0 -85
  63. package/bench-results/signature-profiles-quick-1776237913190.json +0 -85
  64. package/bench-results/wire-codecs-full-1777891019803.json +0 -93
  65. package/bench-results/wire-codecs-full-1777896260964.json +0 -93
  66. package/bench-results/wire-codecs-full-1777901004247.json +0 -93
  67. package/bench-results/wire-codecs-quick-1775972879056.json +0 -93
  68. package/bench-results/wire-codecs-quick-1775983541111.json +0 -93
  69. package/bench-results/wire-codecs-quick-1776237914299.json +0 -93
  70. package/bench-results/wire-codecs-quick-1777841285236.json +0 -93
  71. package/bench-results/wire-codecs-quick-1777841321772.json +0 -93
  72. package/bench-results/wire-codecs-quick-1777841330408.json +0 -93
  73. package/bench-results/wire-codecs-quick-1777852886082.json +0 -93
  74. package/bench-results/wire-codecs-quick-1777852988773.json +0 -93
  75. package/bench-results/wire-codecs-quick-1777870188095.json +0 -93
  76. package/bench-results/wire-codecs-quick-1777870263918.json +0 -93
  77. package/bench-results/wire-codecs-quick-1777870455034.json +0 -93
  78. package/bench-results/wire-codecs-quick-1778816163081.json +0 -93
  79. package/bench-results/wire-codecs-quick-1778843936130.json +0 -93
  80. package/bin/7h3.ts +0 -406
  81. package/conformance/7h3_v0_1.json +0 -77
  82. package/conformance/7h3_v0_1_binary.json +0 -20
  83. package/conformance/aip_v0_1_binary.json +0 -20
  84. package/docker-compose.yaml +0 -77
  85. package/docs/ADOPTION_PLAN.md +0 -120
  86. package/docs/AGENTS.md +0 -77
  87. package/docs/AIP_RFC_v0.1.md +0 -97
  88. package/docs/AI_DECISION_CARD.md +0 -122
  89. package/docs/AI_RUNTIME_POLICY.json +0 -126
  90. package/docs/AI_RUNTIME_POLICY.yaml +0 -110
  91. package/docs/BACKPRESSURE_TUNING.md +0 -65
  92. package/docs/BENCHMARK_CLAIM_MATRIX.md +0 -42
  93. package/docs/BENCHMARK_REPORT_TEMPLATE.md +0 -169
  94. package/docs/BINARY_CODEC_BENCH.md +0 -23
  95. package/docs/CLEAN_CLONE_RUNBOOK.md +0 -36
  96. package/docs/CLOCK_SKEW_POLICY.md +0 -30
  97. package/docs/DISTRIBUTED_REPLAY.md +0 -142
  98. package/docs/FUZZ_CAMPAIGN.md +0 -121
  99. package/docs/GATEWAY.md +0 -195
  100. package/docs/KEY_MANAGEMENT_POLICY.md +0 -53
  101. package/docs/KEY_REVOCATION.md +0 -69
  102. package/docs/MCP_WRAPPER.md +0 -159
  103. package/docs/MIGRATION_GUIDE.md +0 -40
  104. package/docs/OPERATORS.md +0 -184
  105. package/docs/PERF_REGRESSION_POLICY.md +0 -34
  106. package/docs/PROJECT_EXAMINATION_2026-05-31.md +0 -219
  107. package/docs/RELEASE_BENCHMARK_REPORT_2026-05-15.md +0 -135
  108. package/docs/RELEASE_GATE.md +0 -25
  109. package/docs/RELEASE_NOTES_v0.1.0.md +0 -54
  110. package/docs/SECURITY_REVIEW_2026-06-05.md +0 -165
  111. package/docs/TELEMETRY.md +0 -41
  112. package/docs/THREAT_MODEL.md +0 -89
  113. package/docs/VERSIONING_POLICY.md +0 -30
  114. package/docs/assets/banner-github.png +0 -0
  115. package/docs/assets/banner.png +0 -0
  116. package/docs/assets/banner.svg +0 -123
  117. package/eslint.config.js +0 -15
  118. package/fuzz/ts/harness-decode.ts +0 -136
  119. package/fuzz/ts/harness-verify.ts +0 -121
  120. package/fuzz/ts/run.ts +0 -35
  121. package/mcp-server/README.md +0 -38
  122. package/mcp-server/package-lock.json +0 -1187
  123. package/mcp-server/package.json +0 -35
  124. package/mcp-server/src/index.ts +0 -236
  125. package/mcp-server/tsconfig.json +0 -14
  126. package/scripts/aip-framework-quickstart.ts +0 -110
  127. package/scripts/aip-mcp-gateway.ts +0 -38
  128. package/scripts/aip-mcp-wrap-demo.ts +0 -72
  129. package/scripts/aip-quickstart.ts +0 -60
  130. package/scripts/bench-diff.ts +0 -118
  131. package/scripts/bench-protocol-e2e.ts +0 -937
  132. package/scripts/bench-protocol-openloop.ts +0 -1397
  133. package/scripts/bench-replay-cache.ts +0 -76
  134. package/scripts/bench-signature-profiles.ts +0 -180
  135. package/scripts/bench-wire-codecs.ts +0 -161
  136. package/scripts/build-binary-conformance.ts +0 -36
  137. package/scripts/build-release-dashboard.ts +0 -175
  138. package/scripts/canary-rollout.ts +0 -38
  139. package/scripts/mcpGatewayCli.test.ts +0 -116
  140. package/scripts/prepare-aip-package.ts +0 -88
  141. package/scripts/regen-conformance-sigs.ts +0 -18
  142. package/scripts/release-gate.ts +0 -19
  143. package/scripts/validate-runtime-policy.ts +0 -18
  144. package/sdk/browser/index.test.ts +0 -162
  145. package/sdk/browser/index.ts +0 -257
  146. package/sdk/browser/package.json +0 -13
  147. package/sdk/go/cbor.go +0 -551
  148. package/sdk/go/cbor_test.go +0 -232
  149. package/sdk/go/encryption.go +0 -280
  150. package/sdk/go/encryption_test.go +0 -318
  151. package/sdk/go/go.mod +0 -7
  152. package/sdk/go/go.sum +0 -4
  153. package/sdk/go/http.go +0 -135
  154. package/sdk/go/protocol.go +0 -324
  155. package/sdk/go/protocol_test.go +0 -334
  156. package/sdk/go/replay.go +0 -121
  157. package/sdk/go/replay_test.go +0 -149
  158. package/sdk/go/webhook.go +0 -136
  159. package/sdk/pq/package-lock.json +0 -1358
  160. package/sdk/pq/package.json +0 -42
  161. package/sdk/pq/src/index.test.ts +0 -143
  162. package/sdk/pq/src/index.ts +0 -166
  163. package/sdk/pq/tsconfig.json +0 -14
  164. package/sdk/pq/vitest.config.ts +0 -7
  165. package/sdk/python/README.md +0 -18
  166. package/sdk/python/protocol_7h3/__init__.py +0 -46
  167. package/sdk/python/protocol_7h3/encryption.py +0 -252
  168. package/sdk/python/protocol_7h3/http.py +0 -212
  169. package/sdk/python/protocol_7h3/keys.py +0 -149
  170. package/sdk/python/protocol_7h3/pq.py +0 -244
  171. package/sdk/python/protocol_7h3/protocol.py +0 -525
  172. package/sdk/python/protocol_7h3/queue.py +0 -118
  173. package/sdk/python/protocol_7h3/replay.py +0 -98
  174. package/sdk/python/protocol_7h3/webhook.py +0 -116
  175. package/sdk/python/pyproject.toml +0 -40
  176. package/sdk/python/tests/test_conformance.py +0 -110
  177. package/sdk/python/tests/test_encryption.py +0 -206
  178. package/sdk/python/tests/test_http.py +0 -305
  179. package/sdk/python/tests/test_keys.py +0 -417
  180. package/sdk/python/tests/test_queue.py +0 -120
  181. package/sdk/python/tests/test_webhook.py +0 -345
  182. package/sdk/rust/Cargo.lock +0 -371
  183. package/sdk/rust/Cargo.toml +0 -25
  184. package/sdk/rust/README.md +0 -31
  185. package/sdk/rust/fuzz/Cargo.toml +0 -29
  186. package/sdk/rust/fuzz/fuzz_targets/fuzz_canonicalize.rs +0 -46
  187. package/sdk/rust/fuzz/fuzz_targets/fuzz_decode.rs +0 -11
  188. package/sdk/rust/src/bin/aip_mcp_gateway.rs +0 -59
  189. package/sdk/rust/src/http.rs +0 -145
  190. package/sdk/rust/src/keys.rs +0 -161
  191. package/sdk/rust/src/lib.rs +0 -688
  192. package/sdk/rust/src/queue.rs +0 -79
  193. package/sdk/rust/src/webhook.rs +0 -86
  194. package/sdk/rust/tests/conformance.rs +0 -148
  195. package/sdk/rust/tests/gateway.rs +0 -130
  196. package/sdk/rust/tests/http_webhook_queue.rs +0 -201
  197. package/sdk/rust/tests/keys.rs +0 -189
  198. package/sdk/threshold/index.d.ts +0 -68
  199. package/sdk/threshold/index.d.ts.map +0 -1
  200. package/sdk/threshold/index.js +0 -254
  201. package/sdk/threshold/package-lock.json +0 -1361
  202. package/sdk/threshold/package.json +0 -39
  203. package/sdk/threshold/src/index.d.ts +0 -68
  204. package/sdk/threshold/src/index.d.ts.map +0 -1
  205. package/sdk/threshold/src/index.js +0 -254
  206. package/sdk/threshold/src/index.test.ts +0 -238
  207. package/sdk/threshold/src/index.ts +0 -355
  208. package/sdk/threshold/tsconfig.json +0 -19
  209. package/sdk/threshold/vitest.config.ts +0 -12
  210. package/src/agentAdapter.test.ts +0 -48
  211. package/src/agentAdapter.ts +0 -56
  212. package/src/auditLog.test.ts +0 -145
  213. package/src/auditLog.ts +0 -147
  214. package/src/capability.test.ts +0 -504
  215. package/src/capability.ts +0 -380
  216. package/src/cborCodec.test.ts +0 -263
  217. package/src/cborCodec.ts +0 -339
  218. package/src/conformance.test.ts +0 -136
  219. package/src/conformanceVectors.ts +0 -99
  220. package/src/encryption.test.ts +0 -206
  221. package/src/encryption.ts +0 -245
  222. package/src/envelopeCbor.ts +0 -140
  223. package/src/frameworkAdapters.test.ts +0 -290
  224. package/src/frameworkAdapters.ts +0 -261
  225. package/src/gateway.test.ts +0 -343
  226. package/src/gateway.ts +0 -246
  227. package/src/grpcBinding.test.ts +0 -211
  228. package/src/grpcBinding.ts +0 -103
  229. package/src/httpBinding.test.ts +0 -376
  230. package/src/httpBinding.ts +0 -189
  231. package/src/index.ts +0 -39
  232. package/src/keyInfra.test.ts +0 -278
  233. package/src/keyInfra.ts +0 -228
  234. package/src/keyRegistry.ts +0 -59
  235. package/src/keyRotation.test.ts +0 -78
  236. package/src/keyRotation.ts +0 -72
  237. package/src/mcpGateway.test.ts +0 -129
  238. package/src/mcpGateway.ts +0 -250
  239. package/src/mcpTransports.test.ts +0 -92
  240. package/src/mcpTransports.ts +0 -169
  241. package/src/mcpWrapper.test.ts +0 -179
  242. package/src/mcpWrapper.ts +0 -206
  243. package/src/otel.ts +0 -136
  244. package/src/policyEnforcer.test.ts +0 -99
  245. package/src/policyEnforcer.ts +0 -169
  246. package/src/policyTelemetryFeedback.test.ts +0 -25
  247. package/src/policyTelemetryFeedback.ts +0 -38
  248. package/src/protocol.bench.ts +0 -37
  249. package/src/protocol.d.ts.map +0 -1
  250. package/src/protocol.js +0 -294
  251. package/src/protocol.test.ts +0 -155
  252. package/src/protocol.ts +0 -414
  253. package/src/protocolAgent.test.ts +0 -105
  254. package/src/protocolAgent.ts +0 -169
  255. package/src/protocolBinary.test.ts +0 -165
  256. package/src/protocolBinary.ts +0 -312
  257. package/src/protocolCapabilities.ts +0 -70
  258. package/src/protocolFuzz.advanced.test.ts +0 -235
  259. package/src/protocolFuzz.test.ts +0 -111
  260. package/src/protocolNegative.test.ts +0 -97
  261. package/src/protocolReplay.test.ts +0 -71
  262. package/src/protocolReplay.ts +0 -194
  263. package/src/protocolTransport.test.ts +0 -556
  264. package/src/protocolTransport.ts +0 -483
  265. package/src/queueBinding.test.ts +0 -130
  266. package/src/queueBinding.ts +0 -102
  267. package/src/rateLimiter.test.ts +0 -96
  268. package/src/rateLimiter.ts +0 -46
  269. package/src/redisIntegration.test.ts +0 -134
  270. package/src/replayStores.test.ts +0 -273
  271. package/src/replayStores.ts +0 -215
  272. package/src/revocation.test.ts +0 -98
  273. package/src/revocation.ts +0 -0
  274. package/src/routePolicy.test.ts +0 -87
  275. package/src/routePolicy.ts +0 -72
  276. package/src/runtimePolicy.test.ts +0 -49
  277. package/src/runtimePolicy.ts +0 -81
  278. package/src/runtimePolicyManager.test.ts +0 -29
  279. package/src/runtimePolicyManager.ts +0 -50
  280. package/src/runtimePolicyPresets.ts +0 -43
  281. package/src/signedResponse.test.ts +0 -111
  282. package/src/signedResponse.ts +0 -83
  283. package/src/stream.test.ts +0 -254
  284. package/src/stream.ts +0 -417
  285. package/src/telemetry.test.ts +0 -251
  286. package/src/telemetry.ts +0 -299
  287. package/src/webhookBinding.test.ts +0 -144
  288. package/src/webhookBinding.ts +0 -115
  289. package/src/wsBinding.test.ts +0 -221
  290. package/src/wsBinding.ts +0 -200
  291. package/tsconfig.json +0 -15
  292. package/tsconfig.lib.json +0 -23
  293. package/vite.lib.config.ts +0 -16
  294. package/vitest.config.ts +0 -11
@@ -1,1397 +0,0 @@
1
- import { mkdir, writeFile } from 'node:fs/promises'
2
- import http2 from 'node:http2'
3
- import { performance } from 'node:perf_hooks'
4
- import process from 'node:process'
5
- import { WebSocket, WebSocketServer } from 'ws'
6
- import {
7
- canonicalizeEnvelope,
8
- signCanonicalPayloadHmac,
9
- type ProtocolEnvelope,
10
- type ProtocolHeader,
11
- } from '../src/protocol'
12
- import { InMemoryReplayCache } from '../src/protocolReplay'
13
- import {
14
- InMemoryVerificationMaterialCache,
15
- decodeEnvelope,
16
- encodeEnvelope,
17
- receiveEnvelope,
18
- } from '../src/protocolTransport'
19
-
20
- type Mode = 'http' | 'ws' | 'http-binary' | 'http-batch' | 'http-binary-batch' | 'ws-batch' | 'ws-binary' | 'ws-binary-batch'
21
- type ProfileName = 'quick' | 'full'
22
- type RawEnvelope = string | Uint8Array
23
-
24
- interface Profile {
25
- warmupMs: number
26
- measureMs: number
27
- }
28
-
29
- interface Scenario {
30
- mode: Mode
31
- payloadBytes: number
32
- concurrency: number
33
- }
34
-
35
- interface StageTotals {
36
- buildMs: number
37
- canonicalizeMs: number
38
- macMs: number
39
- queueMs: number
40
- transportMs: number
41
- verifyMs: number
42
- decodeMs: number
43
- }
44
-
45
- interface ResultRow {
46
- mode: Mode
47
- payloadBytes: number
48
- concurrency: number
49
- targetOpsPerSecond: number
50
- launched: number
51
- completed: number
52
- dropped: number
53
- dropPct: number
54
- sustainable: boolean
55
- opsPerSecond: number
56
- p50Ms: number
57
- p95Ms: number
58
- p99Ms: number
59
- cpuMsPer10kOps: number
60
- stageBuildMsPerOp: number
61
- stageCanonicalizeMsPerOp: number
62
- stageMacMsPerOp: number
63
- stageQueueMsPerOp: number
64
- stageTransportMsPerOp: number
65
- stageVerifyMsPerOp: number
66
- stageDecodeMsPerOp: number
67
- }
68
-
69
- interface AdaptiveConfig {
70
- enabled: boolean
71
- p99ThresholdMs: number
72
- dropThresholdPct: number
73
- maxDoublings: number
74
- binarySteps: number
75
- }
76
-
77
- interface ScenarioSelection {
78
- ciPreset: boolean
79
- modes: Mode[]
80
- payloadSizes: number[]
81
- concurrencyLevels: number[]
82
- allowUnsafeHttp: boolean
83
- }
84
-
85
- interface TransportTrace {
86
- queueMs: number
87
- transportMs: number
88
- verifyMs: number
89
- decodeMs: number
90
- }
91
-
92
- interface TransportAdapter {
93
- start(): Promise<void>
94
- send(rawEnvelope: RawEnvelope): Promise<TransportTrace>
95
- sendBatch(rawEnvelopes: RawEnvelope[]): Promise<TransportTrace[]>
96
- stop(): Promise<void>
97
- }
98
-
99
- interface BenchEnvelopeTemplate {
100
- headerBase: Omit<ProtocolHeader, 'messageId' | 'timestampMs' | 'nonce'>
101
- intent: 'TASK'
102
- capability: string
103
- }
104
-
105
- interface ServerAck {
106
- ok: boolean
107
- queueMs: number
108
- verifyMs: number
109
- decodeMs: number
110
- error?: string
111
- }
112
-
113
- const ACK_HEADER_OK = 'x-aip-ok'
114
- const ACK_HEADER_QUEUE_MS = 'x-aip-queue-ms'
115
- const ACK_HEADER_VERIFY_MS = 'x-aip-verify-ms'
116
- const ACK_HEADER_DECODE_MS = 'x-aip-decode-ms'
117
-
118
- const SHARED_SECRET = 'bench-shared-secret'
119
- const SHARED_SECRET_RESOLVER = async (): Promise<string> => SHARED_SECRET
120
- const BATCH_SIZE = 32
121
- const MAX_LATENCY_SAMPLES = 200_000
122
- const WS_BINARY_MAGIC = [0x41, 0x49, 0x50, 0x46] as const // AIPF
123
- const WS_BINARY_VERSION = 1
124
- const WS_BINARY_KIND_SINGLE = 1
125
- const WS_BINARY_KIND_BATCH = 2
126
- const PAYLOAD_SIZES = [256, 1024, 4096, 16384]
127
- const CONCURRENCY_LEVELS = [1, 10, 100, 1000]
128
- const PROFILES: Record<ProfileName, Profile> = {
129
- quick: { warmupMs: 300, measureMs: 1500 },
130
- full: { warmupMs: 1200, measureMs: 8000 },
131
- }
132
-
133
- function parseProfile(argv: string[]): ProfileName {
134
- const idx = argv.indexOf('--profile')
135
- if (idx >= 0) {
136
- const value = argv[idx + 1]
137
- if (value === 'quick' || value === 'full') return value
138
- }
139
- return 'quick'
140
- }
141
-
142
- function hasFlag(flag: string, argv: string[]): boolean {
143
- return argv.includes(flag)
144
- }
145
-
146
- function parseNumberArgFromArgv(flag: string, argv: string[], fallback: number): number {
147
- const idx = argv.indexOf(flag)
148
- if (idx < 0) return fallback
149
- const raw = argv[idx + 1]
150
- if (!raw) return fallback
151
- const value = Number(raw)
152
- return Number.isFinite(value) ? value : fallback
153
- }
154
-
155
- function parseCsvArg(flag: string, argv: string[]): string[] {
156
- const idx = argv.indexOf(flag)
157
- if (idx < 0) return []
158
- const raw = argv[idx + 1]
159
- if (!raw) return []
160
- return raw
161
- .split(',')
162
- .map((part) => part.trim())
163
- .filter((part) => part.length > 0)
164
- }
165
-
166
- function parseModeSelection(argv: string[], fallback: Mode[]): Mode[] {
167
- const rawModes = parseCsvArg('--modes', argv)
168
- if (rawModes.length === 0) return fallback
169
- const allowed = new Set<Mode>(['http', 'ws', 'http-binary', 'http-batch', 'http-binary-batch', 'ws-batch', 'ws-binary', 'ws-binary-batch'])
170
- const parsed = rawModes.filter((raw): raw is Mode => allowed.has(raw as Mode))
171
- return parsed.length > 0 ? parsed : fallback
172
- }
173
-
174
- function parseNumberSelection(argv: string[], flag: string, fallback: number[]): number[] {
175
- const rawValues = parseCsvArg(flag, argv)
176
- if (rawValues.length === 0) return fallback
177
- const parsed = rawValues
178
- .map((raw) => Number(raw))
179
- .filter((value) => Number.isFinite(value) && value > 0)
180
- .map((value) => Math.floor(value))
181
- return parsed.length > 0 ? parsed : fallback
182
- }
183
-
184
- function buildScenarioSelection(argv: string[]): ScenarioSelection {
185
- const ciPreset = hasFlag('--ci', argv)
186
- const allowUnsafeHttp = hasFlag('--allow-unsafe-http', argv)
187
- const defaultModes: Mode[] = ciPreset
188
- ? ['ws', 'ws-batch']
189
- : ['http', 'ws', 'http-binary', 'http-batch', 'http-binary-batch', 'ws-batch', 'ws-binary', 'ws-binary-batch']
190
- const defaultPayloads = ciPreset ? [256, 1024, 4096] : PAYLOAD_SIZES
191
- const defaultConcurrency = ciPreset ? [10, 100] : CONCURRENCY_LEVELS
192
-
193
- return {
194
- ciPreset,
195
- modes: parseModeSelection(argv, defaultModes),
196
- payloadSizes: parseNumberSelection(argv, '--payloads', defaultPayloads),
197
- concurrencyLevels: parseNumberSelection(argv, '--concurrency', defaultConcurrency),
198
- allowUnsafeHttp,
199
- }
200
- }
201
-
202
- function enforceHttpGuardrails(selection: ScenarioSelection): void {
203
- if (selection.allowUnsafeHttp) return
204
-
205
- const hasPlainHttp = selection.modes.includes('http')
206
- const hasHighConcurrency = selection.concurrencyLevels.some((level) => level >= 100)
207
-
208
- if (hasPlainHttp && hasHighConcurrency) {
209
- throw new Error(
210
- 'Guardrail: plain http with concurrency >= 100 is blocked by default. Use http-binary-batch/ws-binary-batch, lower concurrency, or pass --allow-unsafe-http for stress runs.',
211
- )
212
- }
213
- }
214
-
215
- function round3(value: number): number {
216
- return Number(value.toFixed(3))
217
- }
218
-
219
- function percentile(sorted: number[], p: number): number {
220
- if (sorted.length === 0) return 0
221
- const index = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))
222
- return sorted[index] ?? 0
223
- }
224
-
225
- function payloadOfSize(bytes: number): string {
226
- return bytes > 0 ? 'x'.repeat(bytes) : ''
227
- }
228
-
229
- function nowMs(): number {
230
- return Date.now()
231
- }
232
-
233
- function delayMs(ms: number): Promise<void> {
234
- return new Promise((resolve) => setTimeout(resolve, ms))
235
- }
236
-
237
- function emptyStages(): StageTotals {
238
- return {
239
- buildMs: 0,
240
- canonicalizeMs: 0,
241
- macMs: 0,
242
- queueMs: 0,
243
- transportMs: 0,
244
- verifyMs: 0,
245
- decodeMs: 0,
246
- }
247
- }
248
-
249
- function addStages(target: StageTotals, source: StageTotals): void {
250
- target.buildMs += source.buildMs
251
- target.canonicalizeMs += source.canonicalizeMs
252
- target.macMs += source.macMs
253
- target.queueMs += source.queueMs
254
- target.transportMs += source.transportMs
255
- target.verifyMs += source.verifyMs
256
- target.decodeMs += source.decodeMs
257
- }
258
-
259
- function targetRateFor(mode: Mode, concurrency: number): number {
260
- const perWorker =
261
- mode === 'http'
262
- ? 650
263
- : mode === 'ws' || mode === 'ws-binary'
264
- ? 900
265
- : mode === 'http-batch'
266
- ? 900
267
- : 1200
268
- return perWorker * concurrency
269
- }
270
-
271
- function isBinaryMode(mode: Mode): boolean {
272
- return mode === 'ws-binary' || mode === 'ws-binary-batch' || mode === 'http-binary' || mode === 'http-binary-batch'
273
- }
274
-
275
- function requireTextEnvelope(rawEnvelope: RawEnvelope): string {
276
- if (typeof rawEnvelope !== 'string') {
277
- throw new Error('HTTP transport requires string envelopes')
278
- }
279
- return rawEnvelope
280
- }
281
-
282
- function normalizeWsBytes(data: WebSocket.RawData): Uint8Array {
283
- if (Buffer.isBuffer(data)) return data
284
- if (data instanceof ArrayBuffer) return new Uint8Array(data)
285
- if (Array.isArray(data)) return Buffer.concat(data)
286
- return Buffer.from(data)
287
- }
288
-
289
- function encodeWsBinaryFrame(id: number, payloads: Uint8Array[]): Buffer {
290
- const size = 4 + 1 + 1 + 4 + 2 + payloads.reduce((total, payload) => total + 4 + payload.byteLength, 0)
291
- const out = Buffer.allocUnsafe(size)
292
- let offset = 0
293
- for (const value of WS_BINARY_MAGIC) {
294
- out[offset] = value
295
- offset += 1
296
- }
297
- out[offset] = WS_BINARY_VERSION
298
- offset += 1
299
- out[offset] = payloads.length === 1 ? WS_BINARY_KIND_SINGLE : WS_BINARY_KIND_BATCH
300
- offset += 1
301
- out.writeUInt32BE(id, offset)
302
- offset += 4
303
- out.writeUInt16BE(payloads.length, offset)
304
- offset += 2
305
- for (const payload of payloads) {
306
- out.writeUInt32BE(payload.byteLength, offset)
307
- offset += 4
308
- out.set(payload, offset)
309
- offset += payload.byteLength
310
- }
311
- return out
312
- }
313
-
314
- function decodeWsBinaryFrame(data: Uint8Array): { id: number; payloads: Uint8Array[] } | null {
315
- if (data.byteLength < 12) return null
316
- for (let index = 0; index < WS_BINARY_MAGIC.length; index += 1) {
317
- if (data[index] !== WS_BINARY_MAGIC[index]) return null
318
- }
319
-
320
- const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
321
- const version = data[4]
322
- const kind = data[5]
323
- if (version !== WS_BINARY_VERSION || (kind !== WS_BINARY_KIND_SINGLE && kind !== WS_BINARY_KIND_BATCH)) return null
324
- const id = view.getUint32(6, false)
325
- const count = view.getUint16(10, false)
326
- if (count === 0 || (kind === WS_BINARY_KIND_SINGLE && count !== 1)) return null
327
-
328
- let offset = 12
329
- const payloads: Uint8Array[] = []
330
- for (let index = 0; index < count; index += 1) {
331
- if (data.byteLength - offset < 4) return null
332
- const length = view.getUint32(offset, false)
333
- offset += 4
334
- if (data.byteLength - offset < length) return null
335
- payloads.push(data.slice(offset, offset + length))
336
- offset += length
337
- }
338
- return offset === data.byteLength ? { id, payloads } : null
339
- }
340
-
341
- function parseServerAck(raw: string): ServerAck {
342
- const parsed = JSON.parse(raw) as Partial<ServerAck>
343
- return {
344
- ok: parsed.ok === true,
345
- queueMs: parsed.queueMs ?? 0,
346
- verifyMs: parsed.verifyMs ?? 0,
347
- decodeMs: parsed.decodeMs ?? 0,
348
- error: parsed.error,
349
- }
350
- }
351
-
352
- function recordLatencySamples(latencies: number[], completed: number, elapsedMs: number, count: number): void {
353
- for (let i = 0; i < count; i += 1) {
354
- if (latencies.length < MAX_LATENCY_SAMPLES) {
355
- latencies.push(elapsedMs)
356
- continue
357
- }
358
- const sampleIndex = Math.floor(Math.random() * (completed + i + 1))
359
- if (sampleIndex < MAX_LATENCY_SAMPLES) {
360
- latencies[sampleIndex] = elapsedMs
361
- }
362
- }
363
- }
364
-
365
- function buildEnvelope(template: BenchEnvelopeTemplate, payload: string, sequence: number): Omit<ProtocolEnvelope, 'signature'> {
366
- return {
367
- header: {
368
- ...template.headerBase,
369
- messageId: `m-${sequence}`,
370
- timestampMs: nowMs(),
371
- nonce: `n-${sequence}`,
372
- },
373
- body: {
374
- intent: template.intent,
375
- content: payload,
376
- capability: template.capability,
377
- correlationId: `corr-${sequence}`,
378
- },
379
- }
380
- }
381
-
382
- async function buildSignEncode(
383
- template: BenchEnvelopeTemplate,
384
- payload: string,
385
- sequence: number,
386
- binary: boolean,
387
- ): Promise<{ raw: RawEnvelope; stage: StageTotals }> {
388
- const stage = emptyStages()
389
-
390
- const buildStart = performance.now()
391
- const unsigned = buildEnvelope(template, payload, sequence)
392
- stage.buildMs = performance.now() - buildStart
393
-
394
- const canonicalizeStart = performance.now()
395
- const canonical = canonicalizeEnvelope(unsigned)
396
- stage.canonicalizeMs = performance.now() - canonicalizeStart
397
-
398
- const macStart = performance.now()
399
- const signature = await signCanonicalPayloadHmac(canonical, SHARED_SECRET)
400
- stage.macMs = performance.now() - macStart
401
-
402
- const envelope: ProtocolEnvelope = {
403
- ...unsigned,
404
- signature: {
405
- alg: 'HS256',
406
- keyId: 'bench-k1',
407
- value: signature,
408
- },
409
- }
410
-
411
- return { raw: encodeEnvelope(envelope, binary ? 'binary' : 'compact'), stage }
412
- }
413
-
414
- class HttpAdapter implements TransportAdapter {
415
- private readonly replayCache = new InMemoryReplayCache(1_000_000)
416
- private readonly verificationMaterialCache = new InMemoryVerificationMaterialCache()
417
- private server: http2.Http2Server | null = null
418
- private clients: http2.ClientHttp2Session[] = []
419
- private clientCursor = 0
420
- private port = 0
421
- private inFlight = 0
422
- private readonly maxServerInFlight: number
423
- private readonly fastAck: boolean
424
- private readonly batchConcurrency: number
425
- private readonly binaryMode: boolean
426
- private readonly binaryBatchMode: boolean
427
- private readonly poolSize: number
428
- private readonly requestTimeoutMs: number
429
-
430
- constructor(
431
- maxServerInFlight = 65536,
432
- fastAck = true,
433
- batchConcurrency = 64,
434
- binaryMode = false,
435
- binaryBatchMode = false,
436
- poolSize = 4,
437
- requestTimeoutMs = 5000,
438
- ) {
439
- this.maxServerInFlight = Math.max(64, maxServerInFlight)
440
- this.fastAck = fastAck
441
- this.batchConcurrency = Math.max(1, batchConcurrency)
442
- this.binaryMode = binaryMode
443
- this.binaryBatchMode = binaryBatchMode
444
- this.poolSize = Math.max(1, poolSize)
445
- this.requestTimeoutMs = Math.max(250, requestTimeoutMs)
446
- }
447
-
448
- private async createClient(): Promise<http2.ClientHttp2Session> {
449
- const client = http2.connect(`http://127.0.0.1:${this.port}`)
450
- await new Promise<void>((resolve, reject) => {
451
- client.once('error', reject)
452
- client.once('connect', () => {
453
- client.off('error', reject)
454
- resolve()
455
- })
456
- })
457
- client.on('error', () => {
458
- // Ignore background session errors; per-request path handles retries/failures.
459
- })
460
- return client
461
- }
462
-
463
- private isClientUsable(client: http2.ClientHttp2Session | undefined): client is http2.ClientHttp2Session {
464
- if (!client) return false
465
- return !(client.closed || client.destroyed)
466
- }
467
-
468
- private async replaceClient(index: number): Promise<http2.ClientHttp2Session> {
469
- const current = this.clients[index]
470
- if (current) {
471
- current.removeAllListeners('error')
472
- try {
473
- current.close()
474
- } catch {
475
- // best effort close
476
- }
477
- }
478
- const replacement = await this.createClient()
479
- this.clients[index] = replacement
480
- return replacement
481
- }
482
-
483
- async start(): Promise<void> {
484
- this.server = http2.createServer({
485
- settings: {
486
- maxConcurrentStreams: 65536,
487
- },
488
- })
489
-
490
- this.server.on('stream', (stream, headers) => {
491
- stream.on('error', () => {
492
- // Ignore per-stream protocol errors under overload; client handles retries.
493
- })
494
-
495
- if (headers[':method'] !== 'POST') {
496
- stream.respond({ ':status': 405 })
497
- stream.end()
498
- return
499
- }
500
-
501
- if (this.inFlight >= this.maxServerInFlight) {
502
- stream.respond({
503
- ':status': 503,
504
- 'content-type': 'application/json',
505
- 'x-retry-after-ms': '2',
506
- })
507
- stream.end(JSON.stringify({ ok: false, error: 'overloaded' }))
508
- return
509
- }
510
-
511
- this.inFlight += 1
512
- const chunks: Buffer[] = []
513
- const queueStart = performance.now()
514
- stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)))
515
- stream.on('end', async () => {
516
- const queueMs = performance.now() - queueStart
517
- const isBinary = headers['content-type'] === 'application/aip-binary'
518
- const rawBody: string | Uint8Array = isBinary ? Buffer.concat(chunks) : Buffer.concat(chunks).toString('utf8')
519
- const path = typeof headers[':path'] === 'string' ? headers[':path'] : '/single'
520
-
521
- if (path === '/batch') {
522
- try {
523
- const isBinaryBatch = headers['content-type'] === 'application/aip-binary-batch'
524
- const payloads = isBinaryBatch
525
- ? (JSON.parse(rawBody as string) as string[]).map((b64) => Buffer.from(b64, 'base64'))
526
- : (JSON.parse(rawBody as string) as string[])
527
- if (!Array.isArray(payloads)) {
528
- stream.respond({ ':status': 400, 'content-type': 'application/json' })
529
- stream.end(JSON.stringify({ ok: false, error: 'batch payload must be array' }))
530
- return
531
- }
532
-
533
- const decodedItems = payloads.map((payload) => {
534
- const decodeStart = performance.now()
535
- const decoded = decodeEnvelope(payload)
536
- const decodeMs = performance.now() - decodeStart
537
- return { payload, decoded, decodeMs }
538
- })
539
-
540
- const results: ServerAck[] = new Array(decodedItems.length)
541
- let nextIndex = 0
542
- const workerCount = Math.min(this.batchConcurrency, decodedItems.length)
543
-
544
- const worker = async () => {
545
- while (nextIndex < decodedItems.length) {
546
- const index = nextIndex
547
- nextIndex += 1
548
- const { decoded, decodeMs } = decodedItems[index]
549
-
550
- if (!decoded.ok || !decoded.envelope) {
551
- results[index] = { ok: false, queueMs, verifyMs: 0, decodeMs, error: 'decode failed' }
552
- continue
553
- }
554
-
555
- const verifyStart = performance.now()
556
- const result = await receiveEnvelope(decoded.envelope, {
557
- nowMs: nowMs(),
558
- replayCache: this.replayCache,
559
- verificationMaterialCache: this.verificationMaterialCache,
560
- secretResolver: SHARED_SECRET_RESOLVER,
561
- })
562
- const verifyMs = performance.now() - verifyStart
563
- results[index] = {
564
- ok: result.ok,
565
- queueMs,
566
- verifyMs,
567
- decodeMs,
568
- error: result.ok ? undefined : result.diagnostics.map((d) => d.message).join('; '),
569
- }
570
- }
571
- }
572
-
573
- await Promise.all(Array.from({ length: workerCount }, () => worker()))
574
- stream.respond({ ':status': results.some((r) => !r.ok) ? 400 : 200, 'content-type': 'application/json' })
575
- stream.end(JSON.stringify(results))
576
- } catch {
577
- stream.respond({ ':status': 500, 'content-type': 'application/json' })
578
- stream.end(JSON.stringify({ ok: false, error: 'internal http batch error' }))
579
- } finally {
580
- this.inFlight -= 1
581
- }
582
- return
583
- }
584
-
585
- try {
586
- const decodeStart = performance.now()
587
- const decoded = decodeEnvelope(rawBody)
588
- const decodeMs = performance.now() - decodeStart
589
- if (!decoded.ok || !decoded.envelope) {
590
- stream.respond({ ':status': 400, 'content-type': 'application/json' })
591
- stream.end(JSON.stringify({ ok: false, queueMs, verifyMs: 0, decodeMs, error: 'decode failed' }))
592
- return
593
- }
594
-
595
- const verifyStart = performance.now()
596
- const result = await receiveEnvelope(decoded.envelope, {
597
- nowMs: nowMs(),
598
- replayCache: this.replayCache,
599
- verificationMaterialCache: this.verificationMaterialCache,
600
- secretResolver: SHARED_SECRET_RESOLVER,
601
- })
602
- const verifyMs = performance.now() - verifyStart
603
- const ack: ServerAck = {
604
- ok: result.ok,
605
- queueMs,
606
- verifyMs,
607
- decodeMs,
608
- error: result.ok ? undefined : result.diagnostics.map((d) => d.message).join('; '),
609
- }
610
-
611
- if (result.ok && this.fastAck) {
612
- stream.respond({
613
- ':status': 204,
614
- [ACK_HEADER_OK]: '1',
615
- [ACK_HEADER_QUEUE_MS]: String(queueMs),
616
- [ACK_HEADER_VERIFY_MS]: String(verifyMs),
617
- [ACK_HEADER_DECODE_MS]: String(decodeMs),
618
- })
619
- stream.end()
620
- return
621
- }
622
-
623
- stream.respond({ ':status': result.ok ? 200 : 400, 'content-type': 'application/json' })
624
- stream.end(JSON.stringify(ack))
625
- } catch {
626
- stream.respond({ ':status': 500, 'content-type': 'application/json' })
627
- stream.end(JSON.stringify({ ok: false, queueMs, verifyMs: 0, decodeMs: 0, error: 'internal http receive error' }))
628
- } finally {
629
- this.inFlight -= 1
630
- }
631
- })
632
-
633
- })
634
-
635
- await new Promise<void>((resolve, reject) => {
636
- this.server?.once('error', reject)
637
- this.server?.listen(0, '127.0.0.1', () => resolve())
638
- })
639
-
640
- const address = this.server.address()
641
- if (!address || typeof address === 'string') {
642
- throw new Error('Could not resolve HTTP server address')
643
- }
644
- this.port = address.port
645
-
646
- this.clients = await Promise.all(Array.from({ length: this.poolSize }, async () => this.createClient()))
647
- }
648
-
649
- async send(rawEnvelope: RawEnvelope): Promise<TransportTrace> {
650
- const body = this.binaryMode ? rawEnvelope : requireTextEnvelope(rawEnvelope)
651
- const { body: responseBody, elapsedMs, headers } = await this.request('/single', body)
652
- const ack = this.fastAck && readHeaderString(headers, ACK_HEADER_OK) === '1' ? ackFromHeaders(headers) : parseServerAck(responseBody)
653
- if (!ack.ok) throw new Error(ack.error ?? 'http single send failed')
654
- return {
655
- queueMs: ack.queueMs,
656
- transportMs: elapsedMs,
657
- verifyMs: ack.verifyMs,
658
- decodeMs: ack.decodeMs,
659
- }
660
- }
661
-
662
- async sendBatch(rawEnvelopes: RawEnvelope[]): Promise<TransportTrace[]> {
663
- let body: string
664
- let contentType: string
665
- if (this.binaryBatchMode) {
666
- contentType = 'application/aip-binary-batch'
667
- body = JSON.stringify(rawEnvelopes.map((e) => Buffer.from(e as Uint8Array).toString('base64')))
668
- } else if (this.binaryMode) {
669
- contentType = 'application/aip-binary'
670
- body = JSON.stringify(rawEnvelopes.map((e) => Buffer.from(e as Uint8Array).toString('base64')))
671
- } else {
672
- contentType = 'application/json'
673
- body = JSON.stringify(rawEnvelopes.map(requireTextEnvelope))
674
- }
675
- const { body: responseBody, elapsedMs } = await this.request('/batch', body, contentType)
676
- const parsed = JSON.parse(responseBody) as ServerAck[]
677
- if (!Array.isArray(parsed)) throw new Error('http batch response is not an array')
678
- return parsed.map((ack) => {
679
- if (!ack.ok) throw new Error(ack.error ?? 'http batch item failed')
680
- return {
681
- queueMs: ack.queueMs,
682
- transportMs: elapsedMs,
683
- verifyMs: ack.verifyMs,
684
- decodeMs: ack.decodeMs,
685
- }
686
- })
687
- }
688
-
689
- private async request(
690
- path: '/single' | '/batch',
691
- body: string | Uint8Array,
692
- contentType?: string,
693
- ): Promise<{ body: string; elapsedMs: number; headers: http2.IncomingHttpHeaders }> {
694
- const maxAttempts = 3
695
- let lastError: Error | null = null
696
-
697
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
698
- const transportStart = performance.now()
699
-
700
- try {
701
- if (this.clients.length === 0) {
702
- throw new Error('HTTP/2 client pool is not started')
703
- }
704
-
705
- const clientIndex = this.clientCursor % this.clients.length
706
- let client = this.clients[clientIndex]
707
- if (!client) {
708
- throw new Error('No HTTP/2 client available in pool')
709
- }
710
- if (!this.isClientUsable(client)) {
711
- client = await this.replaceClient(clientIndex)
712
- }
713
- this.clientCursor = (this.clientCursor + 1) % this.clients.length
714
-
715
- const isBinaryBody = body instanceof Uint8Array
716
- const request = client.request({
717
- ':method': 'POST',
718
- ':path': path,
719
- 'content-type': contentType ?? (isBinaryBody ? 'application/aip-binary' : 'application/json'),
720
- })
721
- request.setTimeout(this.requestTimeoutMs, () => {
722
- request.close(http2.constants.NGHTTP2_CANCEL)
723
- })
724
-
725
- const responsePromise = new Promise<{ body: string; headers: http2.IncomingHttpHeaders }>((resolve, reject) => {
726
- let statusCode = 500
727
- let responseHeaders: http2.IncomingHttpHeaders = {}
728
- const chunks: Buffer[] = []
729
-
730
- request.on('response', (headers) => {
731
- const code = headers[':status']
732
- statusCode = typeof code === 'number' ? code : Number(code ?? 500)
733
- responseHeaders = headers
734
- })
735
-
736
- request.on('data', (chunk) => chunks.push(Buffer.from(chunk)))
737
-
738
- request.on('end', () => {
739
- const payload = Buffer.concat(chunks).toString('utf8')
740
- if (statusCode >= 400) {
741
- const error = new Error(`http status ${statusCode}: ${payload}`)
742
- ;(error as Error & { statusCode?: number; retryAfterMs?: number }).statusCode = statusCode
743
- const retryAfter = responseHeaders['x-retry-after-ms']
744
- if (typeof retryAfter === 'string') {
745
- const parsed = Number(retryAfter)
746
- if (Number.isFinite(parsed) && parsed > 0) {
747
- ;(error as Error & { retryAfterMs?: number }).retryAfterMs = parsed
748
- }
749
- }
750
- reject(error)
751
- return
752
- }
753
- resolve({ body: payload, headers: responseHeaders })
754
- })
755
-
756
- request.on('error', reject)
757
- })
758
-
759
- if (isBinaryBody) {
760
- request.write(body)
761
- request.end()
762
- } else {
763
- request.end(body)
764
- }
765
-
766
- const result = await responsePromise
767
- return { ...result, elapsedMs: performance.now() - transportStart }
768
- } catch (error) {
769
- const typed = error as Error & { statusCode?: number; retryAfterMs?: number; code?: string }
770
- if (typed.code === 'ERR_HTTP2_SESSION_ERROR' || typed.code === 'ERR_HTTP2_STREAM_ERROR') {
771
- const staleIndex = (this.clientCursor - 1 + this.clients.length) % this.clients.length
772
- try {
773
- await this.replaceClient(staleIndex)
774
- } catch {
775
- // keep retry path alive even if immediate replacement fails
776
- }
777
- }
778
- const retriable =
779
- typed.statusCode === 503 ||
780
- typed.code === 'ERR_HTTP2_SESSION_ERROR' ||
781
- typed.code === 'ERR_HTTP2_STREAM_ERROR' ||
782
- typed.code === 'ERR_HTTP2_INVALID_SESSION'
783
- lastError = typed
784
- if (!retriable || attempt === maxAttempts) {
785
- break
786
- }
787
- const backoff = typed.retryAfterMs ?? Math.min(16, 2 ** attempt)
788
- await delayMs(backoff)
789
- }
790
- }
791
-
792
- throw lastError ?? new Error('HTTP request failed')
793
- }
794
-
795
- async stop(): Promise<void> {
796
- const clients = this.clients.splice(0, this.clients.length)
797
- await Promise.all(
798
- clients.map(
799
- (client) =>
800
- new Promise<void>((resolve) => {
801
- client.once('close', () => resolve())
802
- client.close()
803
- }),
804
- ),
805
- )
806
- this.clientCursor = 0
807
- if (!this.server) return
808
- await new Promise<void>((resolve, reject) => {
809
- this.server?.close((error) => (error ? reject(error) : resolve()))
810
- })
811
- this.server = null
812
- }
813
- }
814
-
815
- function readHeaderString(headers: http2.IncomingHttpHeaders, key: string): string | undefined {
816
- const value = headers[key]
817
- if (typeof value === 'string') return value
818
- if (Array.isArray(value)) {
819
- const first = value[0]
820
- return typeof first === 'string' ? first : undefined
821
- }
822
- if (typeof value === 'number') return String(value)
823
- return undefined
824
- }
825
-
826
- function readHeaderNumber(headers: http2.IncomingHttpHeaders, key: string): number {
827
- const raw = readHeaderString(headers, key)
828
- if (!raw) return 0
829
- const value = Number(raw)
830
- return Number.isFinite(value) ? value : 0
831
- }
832
-
833
- function ackFromHeaders(headers: http2.IncomingHttpHeaders): ServerAck {
834
- return {
835
- ok: readHeaderString(headers, ACK_HEADER_OK) === '1',
836
- queueMs: readHeaderNumber(headers, ACK_HEADER_QUEUE_MS),
837
- verifyMs: readHeaderNumber(headers, ACK_HEADER_VERIFY_MS),
838
- decodeMs: readHeaderNumber(headers, ACK_HEADER_DECODE_MS),
839
- }
840
- }
841
-
842
- class WsAdapter implements TransportAdapter {
843
- private readonly replayCache = new InMemoryReplayCache(1_000_000)
844
- private readonly verificationMaterialCache = new InMemoryVerificationMaterialCache()
845
- private server: WebSocketServer | null = null
846
- private client: WebSocket | null = null
847
- private nextId = 1
848
- private readonly pending = new Map<number, { resolve: (value: TransportTrace[]) => void; reject: (error: Error) => void }>()
849
-
850
- private readonly binaryFrames: boolean
851
-
852
- constructor(binaryFrames = false) {
853
- this.binaryFrames = binaryFrames
854
- }
855
-
856
- async start(): Promise<void> {
857
- this.server = new WebSocketServer({ port: 0, host: '127.0.0.1' })
858
-
859
- this.server.on('connection', (socket) => {
860
- socket.on('message', async (data) => {
861
- const binaryFrame = this.binaryFrames ? decodeWsBinaryFrame(normalizeWsBytes(data)) : null
862
- let frame: { id: number; payloads: RawEnvelope[] }
863
- if (binaryFrame) {
864
- frame = binaryFrame
865
- } else {
866
- try {
867
- const parsed = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data)) as {
868
- id: number
869
- payload?: string
870
- payloads?: string[]
871
- }
872
- frame = { id: parsed.id, payloads: parsed.payloads ?? (parsed.payload ? [parsed.payload] : []) }
873
- } catch {
874
- socket.send(Buffer.from(JSON.stringify({ id: 0, ok: false, traces: [], error: 'invalid frame' }), 'utf8'))
875
- return
876
- }
877
- }
878
-
879
- const payloads = frame.payloads
880
- const traces: Array<ServerAck> = []
881
-
882
- for (const payload of payloads) {
883
- const queueStart = performance.now()
884
- const decodeStart = performance.now()
885
- const decoded = decodeEnvelope(payload)
886
- const decodeMs = performance.now() - decodeStart
887
- const queueMs = performance.now() - queueStart
888
-
889
- if (!decoded.ok || !decoded.envelope) {
890
- traces.push({ ok: false, queueMs, decodeMs, verifyMs: 0, error: 'decode failed' })
891
- continue
892
- }
893
-
894
- const verifyStart = performance.now()
895
- const result = await receiveEnvelope(decoded.envelope, {
896
- nowMs: nowMs(),
897
- replayCache: this.replayCache,
898
- verificationMaterialCache: this.verificationMaterialCache,
899
- secretResolver: SHARED_SECRET_RESOLVER,
900
- })
901
- const verifyMs = performance.now() - verifyStart
902
- traces.push({
903
- ok: result.ok,
904
- queueMs,
905
- verifyMs,
906
- decodeMs,
907
- error: result.ok ? undefined : result.diagnostics.map((d) => d.message).join('; '),
908
- })
909
- }
910
-
911
- socket.send(Buffer.from(JSON.stringify({ id: frame.id, traces }), 'utf8'))
912
- })
913
- })
914
-
915
- const port = await new Promise<number>((resolve, reject) => {
916
- this.server?.once('error', reject)
917
- this.server?.once('listening', () => {
918
- const address = this.server?.address()
919
- if (!address || typeof address === 'string') {
920
- reject(new Error('Could not resolve WS server address'))
921
- return
922
- }
923
- resolve(address.port)
924
- })
925
- })
926
-
927
- this.client = await new Promise<WebSocket>((resolve, reject) => {
928
- const socket = new WebSocket(`ws://127.0.0.1:${port}`)
929
- socket.once('open', () => resolve(socket))
930
- socket.once('error', reject)
931
- })
932
-
933
- this.client.on('message', (data) => {
934
- let frame: { id: number; traces: ServerAck[] }
935
- try {
936
- frame = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data)) as {
937
- id: number
938
- traces: ServerAck[]
939
- }
940
- } catch {
941
- return
942
- }
943
-
944
- const pending = this.pending.get(frame.id)
945
- if (!pending) return
946
- this.pending.delete(frame.id)
947
-
948
- if (!Array.isArray(frame.traces) || frame.traces.some((trace) => !trace.ok)) {
949
- const firstError = frame.traces.find((trace) => !trace.ok)?.error ?? 'ws trace error'
950
- pending.reject(new Error(firstError))
951
- return
952
- }
953
-
954
- pending.resolve(
955
- frame.traces.map((trace) => ({
956
- queueMs: trace.queueMs,
957
- transportMs: trace.queueMs + trace.decodeMs + trace.verifyMs,
958
- verifyMs: trace.verifyMs,
959
- decodeMs: trace.decodeMs,
960
- })),
961
- )
962
- })
963
- }
964
-
965
- async send(rawEnvelope: RawEnvelope): Promise<TransportTrace> {
966
- const traces = await this.sendFrame([rawEnvelope])
967
- return traces[0] as TransportTrace
968
- }
969
-
970
- async sendBatch(rawEnvelopes: RawEnvelope[]): Promise<TransportTrace[]> {
971
- return this.sendFrame(rawEnvelopes)
972
- }
973
-
974
- private async sendFrame(payloads: RawEnvelope[]): Promise<TransportTrace[]> {
975
- if (!this.client || this.client.readyState !== WebSocket.OPEN) {
976
- throw new Error('WebSocket client is not open')
977
- }
978
-
979
- const id = this.nextId
980
- this.nextId += 1
981
- const sendStart = performance.now()
982
-
983
- const completion = new Promise<TransportTrace[]>((resolve, reject) => {
984
- this.pending.set(id, { resolve, reject })
985
- })
986
-
987
- if (this.binaryFrames) {
988
- const binaryPayloads = payloads.map((payload) => {
989
- if (!(payload instanceof Uint8Array)) throw new Error('Binary WebSocket mode requires binary envelopes')
990
- return payload
991
- })
992
- this.client.send(encodeWsBinaryFrame(id, binaryPayloads))
993
- } else {
994
- const textPayloads = payloads.map(requireTextEnvelope)
995
- const frame = textPayloads.length === 1 ? { id, payload: textPayloads[0] } : { id, payloads: textPayloads }
996
- this.client.send(Buffer.from(JSON.stringify(frame), 'utf8'))
997
- }
998
- const traces = await completion
999
- const elapsed = performance.now() - sendStart
1000
- return traces.map((trace) => ({ ...trace, transportMs: elapsed }))
1001
- }
1002
-
1003
- async stop(): Promise<void> {
1004
- if (this.client) {
1005
- await new Promise<void>((resolve) => {
1006
- this.client?.once('close', () => resolve())
1007
- this.client?.close()
1008
- })
1009
- this.client = null
1010
- }
1011
-
1012
- if (this.server) {
1013
- await new Promise<void>((resolve, reject) => {
1014
- this.server?.close((error) => (error ? reject(error) : resolve()))
1015
- })
1016
- this.server = null
1017
- }
1018
- }
1019
- }
1020
-
1021
- async function runOpenLoopScenario(profile: Profile, scenario: Scenario, targetRate: number, adaptive?: AdaptiveConfig): Promise<ResultRow> {
1022
- const effectiveMode: Mode =
1023
- scenario.mode === 'http' && scenario.concurrency >= 100 ? 'http-binary-batch' : scenario.mode
1024
- const payload = payloadOfSize(scenario.payloadBytes)
1025
- const template: BenchEnvelopeTemplate = {
1026
- headerBase: {
1027
- version: '7h3/0.1',
1028
- ttlMs: 120_000,
1029
- sender: 'bench.sender',
1030
- recipient: 'bench.receiver',
1031
- },
1032
- intent: 'TASK',
1033
- capability: 'task.plan',
1034
- }
1035
-
1036
- const binaryWire = isBinaryMode(effectiveMode)
1037
- const isHttp = effectiveMode.startsWith('http')
1038
- const batchMode = effectiveMode.endsWith('batch')
1039
- const binaryBatchMode = effectiveMode === 'http-binary-batch'
1040
- const adapter = isHttp
1041
- ? new HttpAdapter(Math.max(4096, scenario.concurrency * 16), true, 64, binaryWire && !batchMode, binaryBatchMode)
1042
- : new WsAdapter(binaryWire)
1043
- await adapter.start()
1044
-
1045
- let sequence = 0
1046
- let inFlight = 0
1047
- let launched = 0
1048
- let completed = 0
1049
- let dropped = 0
1050
- const latencies: number[] = []
1051
- const stageTotals = emptyStages()
1052
- const tokensPerMs = targetRate / 1000
1053
- const maxInFlight = Math.max(scenario.concurrency * 2, 32)
1054
- const minInFlightCap = Math.max(8, scenario.concurrency)
1055
- const maxInFlightCap = Math.max(maxInFlight, scenario.concurrency * 8)
1056
- let dynamicInFlightCap = Math.max(minInFlightCap, Math.min(maxInFlightCap, maxInFlight))
1057
- const baseBatchSize = batchMode ? BATCH_SIZE : 1
1058
- const minBatchSize = batchMode ? 4 : 1
1059
- const maxBatchSize = batchMode ? Math.max(BATCH_SIZE, 64) : 1
1060
- let dynamicBatchSize = baseBatchSize
1061
- let latencyEwmaMs = 0
1062
- const latencyAlpha = 0.15
1063
-
1064
- const tuneFlowControl = (opLatencyMs: number, hadError: boolean): void => {
1065
- latencyEwmaMs = latencyEwmaMs <= 0 ? opLatencyMs : latencyEwmaMs * (1 - latencyAlpha) + opLatencyMs * latencyAlpha
1066
-
1067
- if (hadError) {
1068
- dynamicInFlightCap = Math.max(minInFlightCap, Math.floor(dynamicInFlightCap * 0.85))
1069
- if (batchMode) {
1070
- dynamicBatchSize = Math.max(minBatchSize, Math.floor(dynamicBatchSize * 0.8))
1071
- }
1072
- return
1073
- }
1074
-
1075
- if (latencyEwmaMs > 25) {
1076
- dynamicInFlightCap = Math.max(minInFlightCap, dynamicInFlightCap - 1)
1077
- if (batchMode) dynamicBatchSize = Math.max(minBatchSize, dynamicBatchSize - 1)
1078
- return
1079
- }
1080
-
1081
- if (latencyEwmaMs < 8 && inFlight < dynamicInFlightCap * 0.8) {
1082
- dynamicInFlightCap = Math.min(maxInFlightCap, dynamicInFlightCap + 1)
1083
- if (batchMode) dynamicBatchSize = Math.min(maxBatchSize, dynamicBatchSize + 1)
1084
- }
1085
- }
1086
-
1087
- const runWindow = async (durationMs: number, collect: boolean): Promise<void> => {
1088
- const start = performance.now()
1089
- let credits = 0
1090
- let lastTick = start
1091
-
1092
- while (performance.now() - start < durationMs || inFlight > 0) {
1093
- const now = performance.now()
1094
- const delta = now - lastTick
1095
- lastTick = now
1096
- if (performance.now() - start < durationMs) {
1097
- credits += tokensPerMs * delta
1098
- }
1099
-
1100
- const launches = Math.floor(credits)
1101
- credits -= launches
1102
-
1103
- for (let i = 0; i < launches; i += 1) {
1104
- const batchMode = effectiveMode.endsWith('batch')
1105
- const opCount = batchMode ? dynamicBatchSize : 1
1106
- if (inFlight >= dynamicInFlightCap) {
1107
- credits += 1
1108
- tuneFlowControl(30, true)
1109
- break
1110
- }
1111
-
1112
- inFlight += 1
1113
- launched += opCount
1114
- const opStart = performance.now()
1115
-
1116
- const dispatch = async (): Promise<void> => {
1117
- let hadError = false
1118
- try {
1119
- const raws: RawEnvelope[] = []
1120
- const stage = emptyStages()
1121
- for (let j = 0; j < opCount; j += 1) {
1122
- sequence += 1
1123
- const built = await buildSignEncode(template, payload, sequence, binaryWire)
1124
- raws.push(built.raw)
1125
- addStages(stage, built.stage)
1126
- }
1127
-
1128
- const first = raws[0]
1129
- if (!first) throw new Error('No envelope built for dispatch')
1130
- const traces = batchMode ? await adapter.sendBatch(raws) : [await adapter.send(first)]
1131
- for (const trace of traces) {
1132
- stage.queueMs += trace.queueMs
1133
- stage.transportMs += trace.transportMs
1134
- stage.verifyMs += trace.verifyMs
1135
- stage.decodeMs += trace.decodeMs
1136
- }
1137
-
1138
- if (collect) {
1139
- completed += opCount
1140
- addStages(stageTotals, stage)
1141
- const opLatency = (performance.now() - opStart) / opCount
1142
- recordLatencySamples(latencies, completed, opLatency, opCount)
1143
- tuneFlowControl(opLatency, false)
1144
- }
1145
- } catch (error) {
1146
- hadError = true
1147
- if (collect) {
1148
- const message = error instanceof Error ? error.message : String(error)
1149
- const stillInActiveWindow = performance.now() - start < durationMs
1150
- if (message.includes('http status 503') && stillInActiveWindow) {
1151
- credits += 1
1152
- } else {
1153
- dropped += opCount
1154
- }
1155
- }
1156
- } finally {
1157
- if (collect && hadError) {
1158
- const opLatency = performance.now() - opStart
1159
- tuneFlowControl(opLatency, true)
1160
- }
1161
- }
1162
- }
1163
-
1164
- void dispatch().finally(() => {
1165
- inFlight -= 1
1166
- })
1167
- }
1168
-
1169
- await new Promise<void>((resolve) => setImmediate(resolve))
1170
- }
1171
- }
1172
-
1173
- try {
1174
- await runWindow(profile.warmupMs, false)
1175
-
1176
- const cpuStart = process.cpuUsage()
1177
- await runWindow(profile.measureMs, true)
1178
- const cpuEnd = process.cpuUsage(cpuStart)
1179
-
1180
- const sortedLatencies = latencies.slice().sort((a, b) => a - b)
1181
- const opsPerSecond = completed / (profile.measureMs / 1000)
1182
- const cpuMs = (cpuEnd.user + cpuEnd.system) / 1000
1183
- const stageScale = completed > 0 ? 1 / completed : 0
1184
- const totalAttempts = completed + dropped
1185
- const dropPct = totalAttempts > 0 ? (dropped / totalAttempts) * 100 : 0
1186
-
1187
- const result: ResultRow = {
1188
- mode: effectiveMode,
1189
- payloadBytes: scenario.payloadBytes,
1190
- concurrency: scenario.concurrency,
1191
- targetOpsPerSecond: round3(targetRate),
1192
- launched,
1193
- completed,
1194
- dropped,
1195
- dropPct: round3(dropPct),
1196
- sustainable: false,
1197
- opsPerSecond: round3(opsPerSecond),
1198
- p50Ms: round3(percentile(sortedLatencies, 50)),
1199
- p95Ms: round3(percentile(sortedLatencies, 95)),
1200
- p99Ms: round3(percentile(sortedLatencies, 99)),
1201
- cpuMsPer10kOps: round3(completed > 0 ? cpuMs / (completed / 10_000) : 0),
1202
- stageBuildMsPerOp: round3(stageTotals.buildMs * stageScale),
1203
- stageCanonicalizeMsPerOp: round3(stageTotals.canonicalizeMs * stageScale),
1204
- stageMacMsPerOp: round3(stageTotals.macMs * stageScale),
1205
- stageQueueMsPerOp: round3(stageTotals.queueMs * stageScale),
1206
- stageTransportMsPerOp: round3(stageTotals.transportMs * stageScale),
1207
- stageVerifyMsPerOp: round3(stageTotals.verifyMs * stageScale),
1208
- stageDecodeMsPerOp: round3(stageTotals.decodeMs * stageScale),
1209
- }
1210
-
1211
- if (adaptive) {
1212
- result.sustainable = isSustainable(result, adaptive)
1213
- }
1214
-
1215
- return result
1216
- } finally {
1217
- await adapter.stop()
1218
- }
1219
- }
1220
-
1221
- function isSustainable(row: ResultRow, adaptive: AdaptiveConfig): boolean {
1222
- return row.p99Ms <= adaptive.p99ThresholdMs && row.dropPct <= adaptive.dropThresholdPct
1223
- }
1224
-
1225
- async function runAdaptiveScenario(profile: Profile, scenario: Scenario, adaptive: AdaptiveConfig): Promise<ResultRow> {
1226
- const baseTarget = targetRateFor(scenario.mode, scenario.concurrency)
1227
- let lowTarget = Math.max(1, Math.floor(baseTarget / 8))
1228
- let lowResult = await runOpenLoopScenario(profile, scenario, lowTarget, adaptive)
1229
-
1230
- let backoffSteps = 0
1231
- while (!isSustainable(lowResult, adaptive) && lowTarget > 1 && backoffSteps < 8) {
1232
- lowTarget = Math.max(1, Math.floor(lowTarget / 2))
1233
- lowResult = await runOpenLoopScenario(profile, scenario, lowTarget, adaptive)
1234
- backoffSteps += 1
1235
- }
1236
-
1237
- if (!isSustainable(lowResult, adaptive)) {
1238
- return lowResult
1239
- }
1240
-
1241
- let highTarget = baseTarget
1242
- let highResult = await runOpenLoopScenario(profile, scenario, highTarget, adaptive)
1243
- let best = isSustainable(highResult, adaptive)
1244
- ? {
1245
- ...highResult,
1246
- sustainable: true,
1247
- }
1248
- : {
1249
- ...lowResult,
1250
- sustainable: true,
1251
- }
1252
-
1253
- let doubling = 0
1254
- while (isSustainable(highResult, adaptive) && doubling < adaptive.maxDoublings) {
1255
- lowTarget = highTarget
1256
- lowResult = highResult
1257
- highTarget = Math.floor(highTarget * 2)
1258
- highResult = await runOpenLoopScenario(profile, scenario, highTarget, adaptive)
1259
- if (isSustainable(highResult, adaptive)) {
1260
- best = {
1261
- ...highResult,
1262
- sustainable: true,
1263
- }
1264
- }
1265
- doubling += 1
1266
- }
1267
-
1268
- let lowerBound = lowTarget
1269
- let upperBound = highTarget
1270
- for (let i = 0; i < adaptive.binarySteps; i += 1) {
1271
- const mid = Math.floor((lowerBound + upperBound) / 2)
1272
- if (mid <= lowerBound) break
1273
-
1274
- const result = await runOpenLoopScenario(profile, scenario, mid)
1275
- if (isSustainable(result, adaptive)) {
1276
- lowerBound = mid
1277
- best = {
1278
- ...result,
1279
- sustainable: true,
1280
- }
1281
- } else {
1282
- upperBound = mid
1283
- }
1284
- }
1285
-
1286
- return best
1287
- }
1288
-
1289
- function renderMarkdown(profileName: ProfileName, profile: Profile, rows: ResultRow[], selection: ScenarioSelection): string {
1290
- const lines: string[] = []
1291
- lines.push(`# Protocol Open-Loop Benchmark (${profileName})`)
1292
- lines.push('')
1293
- lines.push(`- Warmup: ${profile.warmupMs}ms`)
1294
- lines.push(`- Measure: ${profile.measureMs}ms`)
1295
- lines.push(`- CI preset: ${selection.ciPreset ? 'enabled' : 'disabled'}`)
1296
- lines.push(`- Modes: ${selection.modes.join(', ')}`)
1297
- lines.push(`- Payloads: ${selection.payloadSizes.join(', ')}`)
1298
- lines.push(`- Concurrency: ${selection.concurrencyLevels.join(', ')}`)
1299
- lines.push(`- Batch size for batch modes: ${BATCH_SIZE}`)
1300
- lines.push('')
1301
- lines.push('| mode | payload | concurrency | target ops/s | completed ops/s | dropped | drop % | sustainable | p50 ms | p95 ms | p99 ms | cpu/10k ms | build | canon | mac | queue | transport | verify | decode |')
1302
- lines.push('| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |')
1303
-
1304
- for (const row of rows) {
1305
- lines.push(
1306
- `| ${row.mode} | ${row.payloadBytes} | ${row.concurrency} | ${row.targetOpsPerSecond} | ${row.opsPerSecond} | ${row.dropped} | ${row.dropPct} | ${row.sustainable ? 'yes' : 'no'} | ${row.p50Ms} | ${row.p95Ms} | ${row.p99Ms} | ${row.cpuMsPer10kOps} | ${row.stageBuildMsPerOp} | ${row.stageCanonicalizeMsPerOp} | ${row.stageMacMsPerOp} | ${row.stageQueueMsPerOp} | ${row.stageTransportMsPerOp} | ${row.stageVerifyMsPerOp} | ${row.stageDecodeMsPerOp} |`,
1307
- )
1308
- }
1309
-
1310
- return `${lines.join('\n')}\n`
1311
- }
1312
-
1313
- async function main(): Promise<void> {
1314
- const profileName = parseProfile(process.argv)
1315
- const profile = PROFILES[profileName]
1316
- if (!profile) {
1317
- throw new Error(`Unknown profile '${profileName}'`)
1318
- }
1319
-
1320
- const adaptive: AdaptiveConfig = {
1321
- enabled: hasFlag('--adaptive', process.argv),
1322
- p99ThresholdMs: parseNumberArgFromArgv('--p99-threshold', process.argv, 200),
1323
- dropThresholdPct: parseNumberArgFromArgv('--drop-threshold-pct', process.argv, 1),
1324
- maxDoublings: Math.max(0, Math.floor(parseNumberArgFromArgv('--adaptive-max-doublings', process.argv, 4))),
1325
- binarySteps: Math.max(0, Math.floor(parseNumberArgFromArgv('--adaptive-binary-steps', process.argv, 6))),
1326
- }
1327
- const selection = buildScenarioSelection(process.argv)
1328
- enforceHttpGuardrails(selection)
1329
-
1330
- const scenarios: Scenario[] = []
1331
- for (const mode of selection.modes) {
1332
- for (const payloadBytes of selection.payloadSizes) {
1333
- for (const concurrency of selection.concurrencyLevels) {
1334
- scenarios.push({ mode, payloadBytes, concurrency })
1335
- }
1336
- }
1337
- }
1338
-
1339
- const rows: ResultRow[] = []
1340
- for (const scenario of scenarios) {
1341
- console.log(`Running [${scenario.mode}] payload=${scenario.payloadBytes}B concurrency=${scenario.concurrency}`)
1342
- const result = adaptive.enabled
1343
- ? await runAdaptiveScenario(profile, scenario, adaptive)
1344
- : await runOpenLoopScenario(profile, scenario, targetRateFor(scenario.mode, scenario.concurrency), adaptive)
1345
- if (!adaptive.enabled) {
1346
- result.sustainable = isSustainable(result, adaptive)
1347
- }
1348
- rows.push(result)
1349
- const sustainability = result.sustainable ? 'yes' : 'no'
1350
- console.log(
1351
- ` target=${result.targetOpsPerSecond} ops/s=${result.opsPerSecond} dropped=${result.dropped} dropPct=${result.dropPct}% p99=${result.p99Ms}ms sustainable=${sustainability}`,
1352
- )
1353
- }
1354
-
1355
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
1356
- const outputDir = 'dist/bench'
1357
- await mkdir(outputDir, { recursive: true })
1358
-
1359
- const jsonPath = `${outputDir}/protocol-openloop.${profileName}.${timestamp}.json`
1360
- const mdPath = `${outputDir}/protocol-openloop.${profileName}.${timestamp}.md`
1361
- const latestJsonPath = `${outputDir}/protocol-openloop.${profileName}.latest.json`
1362
- const latestMdPath = `${outputDir}/protocol-openloop.${profileName}.latest.md`
1363
-
1364
- const payload = {
1365
- profile: profileName,
1366
- warmupMs: profile.warmupMs,
1367
- measureMs: profile.measureMs,
1368
- batchSize: BATCH_SIZE,
1369
- selection,
1370
- adaptive,
1371
- results: rows,
1372
- }
1373
-
1374
- await writeFile(jsonPath, JSON.stringify(payload, null, 2), 'utf8')
1375
- await writeFile(latestJsonPath, JSON.stringify(payload, null, 2), 'utf8')
1376
-
1377
- const markdown = renderMarkdown(profileName, profile, rows, selection)
1378
- await writeFile(mdPath, markdown, 'utf8')
1379
- await writeFile(latestMdPath, markdown, 'utf8')
1380
-
1381
- console.log('')
1382
- console.log(`Wrote ${jsonPath}`)
1383
- console.log(`Wrote ${mdPath}`)
1384
- console.log(`Updated ${latestJsonPath}`)
1385
- console.log(`Updated ${latestMdPath}`)
1386
-
1387
- if (adaptive.enabled && rows.some((row) => !row.sustainable)) {
1388
- console.log('Adaptive gate failed: one or more scenarios are not sustainable at discovered rate')
1389
- process.exitCode = 2
1390
- }
1391
- }
1392
-
1393
- main().catch((error: unknown) => {
1394
- const message = error instanceof Error ? error.stack ?? error.message : String(error)
1395
- console.error(message)
1396
- process.exitCode = 1
1397
- })