@7h3/protocol 0.5.0 → 0.5.1
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.
- package/README.md +6 -1317
- package/agentAdapter.d.ts +26 -0
- package/auditLog.d.ts +54 -0
- package/capability.d.ts +67 -0
- package/cborCodec.d.ts +34 -0
- package/conformanceVectors.d.ts +20 -0
- package/encryption.d.ts +85 -0
- package/envelopeCbor.d.ts +33 -0
- package/frameworkAdapters.d.ts +72 -0
- package/gateway.d.ts +53 -0
- package/grpcBinding.d.ts +29 -0
- package/httpBinding.d.ts +50 -0
- package/index.d.ts +39 -0
- package/index.js +3662 -0
- package/keyInfra.d.ts +64 -0
- package/keyRegistry.d.ts +9 -0
- package/keyRotation.d.ts +20 -0
- package/mcpGateway.d.ts +37 -0
- package/mcpTransports.d.ts +62 -0
- package/mcpWrapper.d.ts +83 -0
- package/otel.d.ts +65 -0
- package/package.json +13 -121
- package/policyEnforcer.d.ts +50 -0
- package/policyTelemetryFeedback.d.ts +11 -0
- package/{src/protocol.d.ts → protocol.d.ts} +1 -2
- package/protocolAgent.d.ts +58 -0
- package/protocolBinary.d.ts +8 -0
- package/protocolCapabilities.d.ts +24 -0
- package/protocolReplay.d.ts +35 -0
- package/protocolTransport.d.ts +73 -0
- package/queueBinding.d.ts +43 -0
- package/rateLimiter.d.ts +18 -0
- package/{src/redisClient.ts → redisClient.d.ts} +25 -75
- package/replayStores.d.ts +98 -0
- package/revocation.d.ts +71 -0
- package/routePolicy.d.ts +30 -0
- package/runtimePolicy.d.ts +24 -0
- package/runtimePolicyManager.d.ts +15 -0
- package/runtimePolicyPresets.d.ts +11 -0
- package/signedResponse.d.ts +21 -0
- package/stream.d.ts +59 -0
- package/telemetry.d.ts +79 -0
- package/webhookBinding.d.ts +30 -0
- package/wsBinding.d.ts +52 -0
- package/.dockerignore +0 -19
- package/.github/dependabot.yml +0 -32
- package/.github/workflows/ci.yml +0 -31
- package/.github/workflows/publish.yml +0 -59
- package/.github/workflows/scorecard.yml +0 -37
- package/7h3.example.yaml +0 -125
- package/CHANGELOG.md +0 -152
- package/CONTRIBUTING.md +0 -82
- package/Dockerfile +0 -73
- package/GOVERNANCE.md +0 -62
- package/SECURITY.md +0 -70
- package/bench-results/replay-cache-full-1777891033256.json +0 -10
- package/bench-results/replay-cache-full-1777896317488.json +0 -10
- package/bench-results/replay-cache-full-1777900993184.json +0 -10
- package/bench-results/replay-cache-full-1777901019285.json +0 -10
- package/bench-results/replay-cache-quick-1777870170126.json +0 -10
- package/bench-results/signature-profiles-quick-1775875160079.json +0 -85
- package/bench-results/signature-profiles-quick-1775983539716.json +0 -85
- package/bench-results/signature-profiles-quick-1776237913190.json +0 -85
- package/bench-results/wire-codecs-full-1777891019803.json +0 -93
- package/bench-results/wire-codecs-full-1777896260964.json +0 -93
- package/bench-results/wire-codecs-full-1777901004247.json +0 -93
- package/bench-results/wire-codecs-quick-1775972879056.json +0 -93
- package/bench-results/wire-codecs-quick-1775983541111.json +0 -93
- package/bench-results/wire-codecs-quick-1776237914299.json +0 -93
- package/bench-results/wire-codecs-quick-1777841285236.json +0 -93
- package/bench-results/wire-codecs-quick-1777841321772.json +0 -93
- package/bench-results/wire-codecs-quick-1777841330408.json +0 -93
- package/bench-results/wire-codecs-quick-1777852886082.json +0 -93
- package/bench-results/wire-codecs-quick-1777852988773.json +0 -93
- package/bench-results/wire-codecs-quick-1777870188095.json +0 -93
- package/bench-results/wire-codecs-quick-1777870263918.json +0 -93
- package/bench-results/wire-codecs-quick-1777870455034.json +0 -93
- package/bench-results/wire-codecs-quick-1778816163081.json +0 -93
- package/bench-results/wire-codecs-quick-1778843936130.json +0 -93
- package/bin/7h3.ts +0 -406
- package/conformance/7h3_v0_1.json +0 -77
- package/conformance/7h3_v0_1_binary.json +0 -20
- package/conformance/aip_v0_1_binary.json +0 -20
- package/docker-compose.yaml +0 -77
- package/docs/ADOPTION_PLAN.md +0 -120
- package/docs/AGENTS.md +0 -77
- package/docs/AIP_RFC_v0.1.md +0 -97
- package/docs/AI_DECISION_CARD.md +0 -122
- package/docs/AI_RUNTIME_POLICY.json +0 -126
- package/docs/AI_RUNTIME_POLICY.yaml +0 -110
- package/docs/BACKPRESSURE_TUNING.md +0 -65
- package/docs/BENCHMARK_CLAIM_MATRIX.md +0 -42
- package/docs/BENCHMARK_REPORT_TEMPLATE.md +0 -169
- package/docs/BINARY_CODEC_BENCH.md +0 -23
- package/docs/CLEAN_CLONE_RUNBOOK.md +0 -36
- package/docs/CLOCK_SKEW_POLICY.md +0 -30
- package/docs/DISTRIBUTED_REPLAY.md +0 -142
- package/docs/FUZZ_CAMPAIGN.md +0 -121
- package/docs/GATEWAY.md +0 -195
- package/docs/KEY_MANAGEMENT_POLICY.md +0 -53
- package/docs/KEY_REVOCATION.md +0 -69
- package/docs/MCP_WRAPPER.md +0 -159
- package/docs/MIGRATION_GUIDE.md +0 -40
- package/docs/OPERATORS.md +0 -184
- package/docs/PERF_REGRESSION_POLICY.md +0 -34
- package/docs/PROJECT_EXAMINATION_2026-05-31.md +0 -219
- package/docs/RELEASE_BENCHMARK_REPORT_2026-05-15.md +0 -135
- package/docs/RELEASE_GATE.md +0 -25
- package/docs/RELEASE_NOTES_v0.1.0.md +0 -54
- package/docs/SECURITY_REVIEW_2026-06-05.md +0 -165
- package/docs/TELEMETRY.md +0 -41
- package/docs/THREAT_MODEL.md +0 -89
- package/docs/VERSIONING_POLICY.md +0 -30
- package/docs/assets/banner-github.png +0 -0
- package/docs/assets/banner.png +0 -0
- package/docs/assets/banner.svg +0 -123
- package/eslint.config.js +0 -15
- package/fuzz/ts/harness-decode.ts +0 -136
- package/fuzz/ts/harness-verify.ts +0 -121
- package/fuzz/ts/run.ts +0 -35
- package/mcp-server/README.md +0 -38
- package/mcp-server/package-lock.json +0 -1187
- package/mcp-server/package.json +0 -35
- package/mcp-server/src/index.ts +0 -236
- package/mcp-server/tsconfig.json +0 -14
- package/scripts/aip-framework-quickstart.ts +0 -110
- package/scripts/aip-mcp-gateway.ts +0 -38
- package/scripts/aip-mcp-wrap-demo.ts +0 -72
- package/scripts/aip-quickstart.ts +0 -60
- package/scripts/bench-diff.ts +0 -118
- package/scripts/bench-protocol-e2e.ts +0 -937
- package/scripts/bench-protocol-openloop.ts +0 -1397
- package/scripts/bench-replay-cache.ts +0 -76
- package/scripts/bench-signature-profiles.ts +0 -180
- package/scripts/bench-wire-codecs.ts +0 -161
- package/scripts/build-binary-conformance.ts +0 -36
- package/scripts/build-release-dashboard.ts +0 -175
- package/scripts/canary-rollout.ts +0 -38
- package/scripts/mcpGatewayCli.test.ts +0 -116
- package/scripts/prepare-aip-package.ts +0 -88
- package/scripts/regen-conformance-sigs.ts +0 -18
- package/scripts/release-gate.ts +0 -19
- package/scripts/validate-runtime-policy.ts +0 -18
- package/sdk/browser/index.test.ts +0 -162
- package/sdk/browser/index.ts +0 -257
- package/sdk/browser/package.json +0 -13
- package/sdk/go/cbor.go +0 -551
- package/sdk/go/cbor_test.go +0 -232
- package/sdk/go/encryption.go +0 -280
- package/sdk/go/encryption_test.go +0 -318
- package/sdk/go/go.mod +0 -7
- package/sdk/go/go.sum +0 -4
- package/sdk/go/http.go +0 -135
- package/sdk/go/protocol.go +0 -324
- package/sdk/go/protocol_test.go +0 -334
- package/sdk/go/replay.go +0 -121
- package/sdk/go/replay_test.go +0 -149
- package/sdk/go/webhook.go +0 -136
- package/sdk/pq/package-lock.json +0 -1358
- package/sdk/pq/package.json +0 -42
- package/sdk/pq/src/index.test.ts +0 -143
- package/sdk/pq/src/index.ts +0 -166
- package/sdk/pq/tsconfig.json +0 -14
- package/sdk/pq/vitest.config.ts +0 -7
- package/sdk/python/README.md +0 -18
- package/sdk/python/protocol_7h3/__init__.py +0 -46
- package/sdk/python/protocol_7h3/encryption.py +0 -252
- package/sdk/python/protocol_7h3/http.py +0 -212
- package/sdk/python/protocol_7h3/keys.py +0 -149
- package/sdk/python/protocol_7h3/pq.py +0 -244
- package/sdk/python/protocol_7h3/protocol.py +0 -525
- package/sdk/python/protocol_7h3/queue.py +0 -118
- package/sdk/python/protocol_7h3/replay.py +0 -98
- package/sdk/python/protocol_7h3/webhook.py +0 -116
- package/sdk/python/pyproject.toml +0 -40
- package/sdk/python/tests/test_conformance.py +0 -110
- package/sdk/python/tests/test_encryption.py +0 -206
- package/sdk/python/tests/test_http.py +0 -305
- package/sdk/python/tests/test_keys.py +0 -417
- package/sdk/python/tests/test_queue.py +0 -120
- package/sdk/python/tests/test_webhook.py +0 -345
- package/sdk/rust/Cargo.lock +0 -371
- package/sdk/rust/Cargo.toml +0 -25
- package/sdk/rust/README.md +0 -31
- package/sdk/rust/fuzz/Cargo.toml +0 -29
- package/sdk/rust/fuzz/fuzz_targets/fuzz_canonicalize.rs +0 -46
- package/sdk/rust/fuzz/fuzz_targets/fuzz_decode.rs +0 -11
- package/sdk/rust/src/bin/aip_mcp_gateway.rs +0 -59
- package/sdk/rust/src/http.rs +0 -145
- package/sdk/rust/src/keys.rs +0 -161
- package/sdk/rust/src/lib.rs +0 -688
- package/sdk/rust/src/queue.rs +0 -79
- package/sdk/rust/src/webhook.rs +0 -86
- package/sdk/rust/tests/conformance.rs +0 -148
- package/sdk/rust/tests/gateway.rs +0 -130
- package/sdk/rust/tests/http_webhook_queue.rs +0 -201
- package/sdk/rust/tests/keys.rs +0 -189
- package/sdk/threshold/index.d.ts +0 -68
- package/sdk/threshold/index.d.ts.map +0 -1
- package/sdk/threshold/index.js +0 -254
- package/sdk/threshold/package-lock.json +0 -1361
- package/sdk/threshold/package.json +0 -39
- package/sdk/threshold/src/index.d.ts +0 -68
- package/sdk/threshold/src/index.d.ts.map +0 -1
- package/sdk/threshold/src/index.js +0 -254
- package/sdk/threshold/src/index.test.ts +0 -238
- package/sdk/threshold/src/index.ts +0 -355
- package/sdk/threshold/tsconfig.json +0 -19
- package/sdk/threshold/vitest.config.ts +0 -12
- package/src/agentAdapter.test.ts +0 -48
- package/src/agentAdapter.ts +0 -56
- package/src/auditLog.test.ts +0 -145
- package/src/auditLog.ts +0 -147
- package/src/capability.test.ts +0 -504
- package/src/capability.ts +0 -380
- package/src/cborCodec.test.ts +0 -263
- package/src/cborCodec.ts +0 -339
- package/src/conformance.test.ts +0 -136
- package/src/conformanceVectors.ts +0 -99
- package/src/encryption.test.ts +0 -206
- package/src/encryption.ts +0 -245
- package/src/envelopeCbor.ts +0 -140
- package/src/frameworkAdapters.test.ts +0 -290
- package/src/frameworkAdapters.ts +0 -261
- package/src/gateway.test.ts +0 -343
- package/src/gateway.ts +0 -246
- package/src/grpcBinding.test.ts +0 -211
- package/src/grpcBinding.ts +0 -103
- package/src/httpBinding.test.ts +0 -376
- package/src/httpBinding.ts +0 -189
- package/src/index.ts +0 -39
- package/src/keyInfra.test.ts +0 -278
- package/src/keyInfra.ts +0 -228
- package/src/keyRegistry.ts +0 -59
- package/src/keyRotation.test.ts +0 -78
- package/src/keyRotation.ts +0 -72
- package/src/mcpGateway.test.ts +0 -129
- package/src/mcpGateway.ts +0 -250
- package/src/mcpTransports.test.ts +0 -92
- package/src/mcpTransports.ts +0 -169
- package/src/mcpWrapper.test.ts +0 -179
- package/src/mcpWrapper.ts +0 -206
- package/src/otel.ts +0 -136
- package/src/policyEnforcer.test.ts +0 -99
- package/src/policyEnforcer.ts +0 -169
- package/src/policyTelemetryFeedback.test.ts +0 -25
- package/src/policyTelemetryFeedback.ts +0 -38
- package/src/protocol.bench.ts +0 -37
- package/src/protocol.d.ts.map +0 -1
- package/src/protocol.js +0 -294
- package/src/protocol.test.ts +0 -155
- package/src/protocol.ts +0 -414
- package/src/protocolAgent.test.ts +0 -105
- package/src/protocolAgent.ts +0 -169
- package/src/protocolBinary.test.ts +0 -165
- package/src/protocolBinary.ts +0 -312
- package/src/protocolCapabilities.ts +0 -70
- package/src/protocolFuzz.advanced.test.ts +0 -235
- package/src/protocolFuzz.test.ts +0 -111
- package/src/protocolNegative.test.ts +0 -97
- package/src/protocolReplay.test.ts +0 -71
- package/src/protocolReplay.ts +0 -194
- package/src/protocolTransport.test.ts +0 -556
- package/src/protocolTransport.ts +0 -483
- package/src/queueBinding.test.ts +0 -130
- package/src/queueBinding.ts +0 -102
- package/src/rateLimiter.test.ts +0 -96
- package/src/rateLimiter.ts +0 -46
- package/src/redisIntegration.test.ts +0 -134
- package/src/replayStores.test.ts +0 -273
- package/src/replayStores.ts +0 -215
- package/src/revocation.test.ts +0 -98
- package/src/revocation.ts +0 -0
- package/src/routePolicy.test.ts +0 -87
- package/src/routePolicy.ts +0 -72
- package/src/runtimePolicy.test.ts +0 -49
- package/src/runtimePolicy.ts +0 -81
- package/src/runtimePolicyManager.test.ts +0 -29
- package/src/runtimePolicyManager.ts +0 -50
- package/src/runtimePolicyPresets.ts +0 -43
- package/src/signedResponse.test.ts +0 -111
- package/src/signedResponse.ts +0 -83
- package/src/stream.test.ts +0 -254
- package/src/stream.ts +0 -417
- package/src/telemetry.test.ts +0 -251
- package/src/telemetry.ts +0 -299
- package/src/webhookBinding.test.ts +0 -144
- package/src/webhookBinding.ts +0 -115
- package/src/wsBinding.test.ts +0 -221
- package/src/wsBinding.ts +0 -200
- package/tsconfig.json +0 -15
- package/tsconfig.lib.json +0 -23
- package/vite.lib.config.ts +0 -16
- 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
|
-
})
|