fhEVM JS SDK 内存泄漏压力测试框架实战:基于 localstack 的 WASM 内存稳态检测
fhEVM JS SDK 内存泄漏压力测试框架实战基于 localstack 的 WASM 内存稳态检测【免费下载链接】fhevmFHEVM, a full-stack framework for integrating Fully Homomorphic Encryption (FHE) with blockchain applications项目地址: https://gitcode.com/GitHub_Trending/fh/fhevm导读fhEVM 的 JavaScript SDK 通过 WASM 模块tfhe加密、tkms解密承载全同态加密运算而 WASM 线性内存只增不减、模块又是进程级单例传统 create/destroy 型测试根本无法探测泄漏。本文以 sdk/js-sdk/test/memleaks/README.md 为核心完整讲解这套内存泄漏压力测试框架的设计动机、六个隔离场景的构造原理、趋势检测算法与结果判读方法并结合仓库源码逐层印证。读完你将掌握如何在真实localstack链上运行长时间 FHE 加密/解密/序列化压测如何区分健康的热身增长与真正无界的泄漏以及如何用tfheMemory/tkmsMemory两个指标对 WASM 层内存做可靠的门禁判定。一、这套框架要解决的问题1.1 为什么需要专门的内存泄漏压测tfhe/tkms两个 WASM 运行时是进程级单例initTfheModulesrc/core/modules/encrypt/module/init-p.ts会把每个版本的初始化 Promise 永久缓存且不存在terminateThreadPool之类的清理路径。这意味着 SDK 没有销毁后回到基线的卸载机制因此不能写成创建 → 销毁 → 断言基线恢复的常规测试——没有 teardown 可以回到。唯一可行的形态是长时间运行的进程内稳态循环持续观察内存是趋于平台期预期行为——缓存填充、线程池缓冲区、JIT 预热还是随操作数无界增长即泄漏。1.2 WebAssembly.Memory 的固有特性根据规范WebAssembly.Memory只能增长、不能收缩。所以WASM 内存没缩小本身永远不能作为泄漏证据——只有与操作数成比例的、持续不衰减的增长才算。框架中的每个场景都以这条趋势为判据而不是看绝对数值见 support/trendDetector.ts。这一约束直接决定了结果判读的规则健康进程在热身阶段缓存填充、线程池启动、JIT 稳定也会增长一次但随后增速应衰减趋近于零真正的泄漏则会保持相同甚至更差的增速持续攀升。二、快速开始运行框架2.1 前置条件需要有一个正在运行的localstackDocker 链参见 test/scripts/localstack-restart.sh或者通过--restart-localstack让框架自动拉起它。注意这是真实链上环境而非 cleartext mock——只有真实环境才能暴露真实的 WASM 生命周期问题。2.2 常用命令# 从 sdk/js-sdk 目录执行 node test/memleaks/run.mjs --restart-localstack --scenario clientChurn --iterations 500 node test/memleaks/run.mjs --scenario clientReuse --iterations 5000 node test/memleaks/run.mjs --scenario all --duration-seconds 1800 node test/memleaks/run.mjs --help全部可用参数如下来自 run.mjs 的帮助输出 与 main.ts 的参数解析参数含义默认值--scenario name\|all逗号分隔的场景名或all全部场景--iterations n迭代次数上限各场景自身的默认值--duration-seconds n墙钟时长上限可与--iterations并存先到者结束无--sample-interval-ms n采样周期2000 ms--warmup n从趋势检测中排除的热身迭代数迭代上限的 10%封顶 50--restart-localstack运行前重启 localstack Docker 栈false--fhevm-cli-profile name转发给localstack-restart.sh的 profile 文件名无--out dirJSONL 采样输出目录test/memleaks/reports-h, --help帮助—值得注意的默认值设计--warmup默认随运行长度缩放Math.min(50, Math.max(1, floor(iterationLimit * 0.1)))而不是固定常数。原因在 main.ts 中写得很清楚固定值如 50会在短的手动冒烟运行如--iterations 50中把全部采样都吞进热身期导致没有数据可分类。2.3 调度器如何工作run.mjs只是一个薄调度器它以NODE_OPTIONS--expose-gc环境变量通过tsx重新执行main.ts。这样采样器能在每次测量前强制触发一次 GCglobal.gc()否则 GC 调度噪声会淹没信号。调度器还做了一件容易被忽略的事——睡眠抑制运行可能持续几十分钟见各场景的defaultIterationsDuration所以它会用平台对应的机制防止系统休眠中断采样macOS 用caffeinate -iLinux 用systemd-inhibit --whatidle:sleep抑制剂二进制不存在时降级为不带抑制运行并打印警告没有已知等价物的平台则直接跳过。输出以运行表格打印到控制台同时逐行追加写入test/memleaks/reports/scenario.jsonl便于运行后离线分析或绘图。main.ts还强制把process.env.CHAIN设为localstackv1 只针对 localstack 链避免被调用者 shell 里的CHAIN环境变量干扰并定义了MAX_CONSECUTIVE_ITERATION_FAILURES 10连续 10 次迭代失败即中止通常是 localstack 掉线的信号。主流程最后强制退出进程——因为 tfhe/tkms 的 WASM 线程池没有拆除路径会让事件循环永久存活自然退出只会挂死。三、六个场景逐一隔离泄漏面设计核心思想每个场景只隔离一个泄漏面。如果把所有操作混进一个循环一旦出现增长无法判断是哪个环节造成的。3.1clientReuse—— 对照组一个长期存活的客户端循环执行encryptValuesdecryptValues针对一个稳定的链上 handle。目标是单次操作路径构建/解析密文列表即 src/core/modules/encrypt/module/api-p.ts 中的buildWithProofPacked——它在finally块中释放自己的 wasm-bindgen 对象从源码看是干净的。这个场景同时充当对照组如果连它也出现无界增长那么首先该怀疑的是测试框架或检测器本身而不是 SDK。默认 200 次迭代、约 1 小时。3.2clientChurn—— 强制密钥重新反序列化每次迭代驱逐全局 FHE 密钥缓存并创建全新客户端强制每次迭代执行一次公钥 CRS 反序列化。目标是 api-p.ts 中的deserializeFheEncryptionPublicKey/deserializeFheEncryptionCrs源码中位于api-p.ts的 L438 与 L465 附近其 JS 包装类TfheCompactPkeCrsImpl/TfheCompactPublicKeyImpl从不对原生句柄调用.free()——这与buildWithProofPacked恰好相反。阅读该场景输出前有两点必须先知道第一创建 N 个新客户端并不能压到这个路径。globalFheEncryptionKeyCachesrc/core/key/FheEncryptionKeyCache-p.ts是进程级单例以 relayer URL 为键、采用 first write wins 语义——密钥在进程生命周期内每个 relayer URL 只反序列化一次之后所有客户端只是 await 同一条缓存项。缓存自身的文档注释写明要强制重新获取请在ensureBytes前调用remove(relayerUrl)。所以该场景在每次迭代前调用globalFheEncryptionKeyCache.remove(relayerUrl)这正是让旧条目的 wasm 对象变得不可达从而可被 GC/终结器回收的前提。从 scenarios/clientChurn.ts 可以看到这一调用序列remove()→createFhevmEncryptClient()→await client.ready→encryptValue()。第二缺少.free()并不自动等于泄漏。项目内置的 tfhe glue 是--weak-refs的 wasm-bindgen 构建CompactPkeCrs和TfheCompactPublicKey在构造时都会向FinalizationRegistry注册自己见 src/wasm/tfhe/v1.6.2/tfhe.js 中大量new FinalizationRegistry(ptr wasm.__wbg_..._free(ptr, 1))的生成代码。因此一旦 JS 包装对象不可达且 GC 运行待处理的终结器WASM 内存仍可能被回收——而迭代间的remove() 强制global.gc()恰好应该触发这一机制。如果在这种条件下仍然持续增长说明有东西让旧密钥保持可达或终结器路径没有真正执行——无论哪种都是真实发现而非想当然的结论。3.3roundtrip—— 完整交易周期完整的 加密 → 提交交易 → 等待回执 → 用户解密 → 公开解密 周期跑在真实的FHETest合约上。它覆盖了 ethers provider/signer/contract/交易生命周期这些纯客户端场景永远触碰不到的代码路径。由于每笔交易有延迟、localstack 吞吐有限迭代次数远少于其他场景。这是真实运行中唯一出现真实增长的场景tfheMemory以加速速率增长40 次迭代中从 4.4KB/次 → 115.8KB/次——与rss/external不同WASM 内存无法由采样相位噪声产生这种形态因此值得深究。源码级排查排除了以明文值作键的 JS 侧缓存、decryptPublicValues中的 tfhe 模块参与它是 tkms 专属的、以及tx.wait()后任何绕回加密模块的代码路径。目前最有可能尚未独立验证的解释是WASM 侧分配器碎片化roundtrip也是唯一一个每次迭代加密不同明文值的场景clearValue counter % 256而其他场景每次都加密相同的固定值——这正是 scenarios/valueChurn.ts 要单独隔离的变量。3.4valueChurn—— 隔离变值加密假设一个长期存活客户端每次迭代用不同的 uint8 值counter % 256执行encryptValue。没有交易、没有解密——专门把变明文这个变量从roundtrip的其他行为中剥离出来而且跳过了 tx-wait/解密的中继往返运行快得多仅剩每次迭代的输入证明中继调用。如果分配器碎片化假设成立那么当值循环在第 256 次迭代后重复时每种分配形态都已见过一次这里的增长应该减速/平台化而真正的无界泄漏不会在意这个边界会继续攀升。从源码看迭代体只有一行加密调用干净利落地只保留这一个变量。3.5permitChurn—— tkms 侧的对应物roundtrip有意在setup()里签一个 permit 并在所有迭代中复用贴近真实会话行为也为了把该泄漏面排除在加密/交易/解密测量之外。permitChurn则正好相反一个长期存活客户端每次迭代执行全新的generateTransportKeyPair()signLegacyDecryptionPermit()signUnifiedDecryptionPermit()——没有交易、没有中继解密调用。它隔离的是反复的 ML-KEM 传输密钥对生成和 EIP-712 permit 签名legacy V1 与 unified V2 两条路径是否单独泄漏 tkms WASM 内存clientChurn/valueChurn只测了 tfhe/加密侧。三个操作纯本地无网络 I/O所以单次迭代远快于任何中继绑定场景——默认 9000 次迭代、约 1 分钟。每次迭代还会把传输密钥对和两份已签 permit 经过 serialize/parse 往返一遍以顺带压测 tkms 侧 WASM 边界的反序列化路径。从 scenarios/permitChurn.ts 可以看到完整的serializeTransportKeyPair/parseTransportKeyPair/serializeSignedDecryptionPermit/parseSignedDecryptionPermit调用链。3.6providerChurn—— 非 WASM 的 ethers 层这不是 WASM/FHE 测试。每次迭代创建一个临时的ethers.JsonRpcProvider signer做一次读调用后丢弃。它隔离 ethers 层自身的 listener/socket/interval 泄漏——一种独立且常见的泄漏类别在其他场景内部是看不见的。它还镜像了 test/fheTest/setup-ethers.ts 自身的构造方式每个配置一个全新JsonRpcProvider从不.destroy()因此也顺带验证该模式在大规模重复时是否安全。四、阅读输出趋势判定与门禁指标4.1 运行表格与趋势摘要每个场景打印运行表格迭代号、已用时间、RSS 及其增量、tfhe/tkms WASM 内存及其增量、累计 GC 次数以及结尾的趋势摘要--- clientChurn: trend summary --- rss plateauing first-half 12.0KB/iter - second-half 0.4KB/iter (peak 3.1MB above baseline) tfheMemory ⚠ GROWING first-half 8.0KB/iter - second-half 7.6KB/iter (peak 4.2MB above baseline)一个指标被归类为growing的条件是运行后一半的增速相对于前一半没有明显衰减或超出绝对上限——精确规则见 support/trendDetector.ts。表格的基线是按指标分别跟踪的而非一次性快照首个样本RSS 从第 0 个 tick 起就有效而 tfhe/tkms WASM 内存要等客户端真正初始化对应模块后才被定义见 support/reporter.ts 的updateMetricBaselines。如果共用一个基线WASM 的增量列会永远空白。4.2 为什么只有 WASM 内存参与门禁判定只有tfheMemory/tkmsMemory决定退出码外加任何指标的绝对上限突破。rss/heapUsed/external/arrayBuffers仍会被计算和打印但真实运行显示它们每迭代会波动数十 MB每次迭代工作期间的大额瞬时分配大多在下一次采样前被 GC 回收对这种锯齿数据做两半线性拟合仅凭哪些点恰好落在峰/谷附近就可能产生伪斜率——无论噪声地板怎么调。WebAssembly.Memory只增不减所以tfheMemory/tkmsMemory不存在相位错开的谷——那里的持续攀升就是真实的。因此它们是被打印为(informational — process-level, does not gate)的进程级指标的例外值得一瞥但不能当判决。4.3 趋势检测算法逐层拆解trendDetector.ts 的默认参数参数默认值含义warmupIterations50迭代数低于此值的采样被排除一次性热身增长minSamplesAfterWarmup10热身后可分类所需的最少样本数不足则判insufficient-datagrowthRatioThreshold0.8后半斜率仍是前一半的 80% 以上即视为未显著衰减absoluteCeilingBytes512 MiB高出基线的硬性安全网上限与趋势形态无关smoothingWindowCount10热身后的采样折叠成的窗口数判定管线分四步排除热身期基线、峰值、上限都在热身窗口之后计算——一次性启动成本模块初始化、线程池启动、首次网络拉取会合法地远超稳态增长算进去会得到完全由热身驱动的误导性 peak XXX MB above baseline。窗口化平滑把热身后的采样按目标窗口数折叠每个窗口取中位数smoothByWindow。这能抵消 GC/分配器的锯齿噪声——真实泄漏会把整个分布含谷值向上推移因此能扛过中位数而围绕平坦基线的噪声会相互抵消。对 tfhe/tkms 而言这近乎恒等变换WASM 内存只增窗口内几乎没有可平滑的散布。两半线性回归对平滑后的点做最小二乘斜率拟合linearRegressionSlope比较前半斜率与后半斜率。自适应噪声地板不用一个对所有指标通用的固定地板而是从本次运行自身观测到的散布平滑后热身期取值的一个标准差分摊到迭代跨度上推导——噪声大的指标得到宽松地板近确定性的指标保持紧密敏感。WASM 页数只按固定 64KB 步进移动RSS 却会在样本间抖动数 MB统一常数只会让两者之一校准失败。isGrowing的最终判定为超出绝对上限或后半斜率高于噪声地板 且前半斜率低于噪声地板 或 后半斜率 ≥ 前半斜率 × 0.8。门禁集GATING_METRICS { tfheMemory, tkmsMemory }见 trendDetector.ts 的GATING_METRICShasActionableGrowth()的语义是门禁指标被判growing或任意指标含非门禁突破绝对上限即视为异常。五、采样器与 WASM 内存读取5.1 MemorySamplersupport/memorySampler.ts 负责周期性快照。关键实现点每次tick()前若global.gc可用则强制 GC这正是--expose-gc的意义所在用PerformanceObserver监听gc事件类型累计 GC 次数与耗时输出表格中的gc列采样同时写入 JSONL每样本一行stop()时会补一个最终快照保证即使落在两个周期 tick 之间也能捕获最后状态定时器unref()化避免干扰主循环退出。5.2 同步 WASM 内存读取器support/wasmMemory.ts 说明了一个重要的架构细节SDK 自带的getTfheModuleInfo()/getTkmsModuleInfo()是async的每次调用都重新解析loadTfheLib/loadKmsLib和注册表项并返回完整模块信息结构而逐 tick 的采样器需要能直接从定时器回调调用的同步() WasmMemoryInfo | undefined。因此createTfheMemoryReader/createTkmsMemoryReader一次性解析出已初始化的单例 glue 模块引用其getWasmInfo()每次调用读取 WASM 实例的当前线性内存大小byteLength与pages。注意读取器不会自行触发模块初始化——必须等某个使用该精确版本的客户端.ready已 resolve 过至少一次后再创建。另外localstack链在协议解析中把未设置的kms模块版本解析为DEFAULT_TKMS_VERSION若将来把场景指向其他链这个默认值必须复查。5.3 场景契约scenarios/scenario.ts 定义了统一契约每个场景只需提供setup()一次性准备返回iterate、可选的readTfheMemory/readTkmsMemory和teardown以及defaultIterations/defaultIterationsDuration。循环边界、采样节奏、错误处理全部归main.ts所有——场景本身永远只描述一次迭代做什么。main.ts还有一个精妙的细节每次迭代后await setImmediateP()强制一次真实的宏任务边界。原因是纯本地 WASM/密码学计算场景如permitChurn的每个await都只走微任务队列而 Node 会先排空微任务再检查定时器——没有 I/O 的紧凑快速迭代循环会饿死定时器阶段导致采样器setInterval整个运行期间都无法触发。setImmediate保证定期打印在任何场景实现方式下都成立。六、当前状态与后续规划v1 仅是独立脚本——没有 vitest 冒烟测试没有 CI 接线这是刻意为之。trendDetector.ts 中的阈值热身窗口、增速比容差、绝对上限目前都是占位值。在它们经过真实运行数据验证之前就作为 CI 门禁上线要么会因 GC/热身噪声而抖动失败要么给出什么都没检查的虚假安全感。既定计划是先手动运行或作为定时任务用真实数据调优阈值之后再提炼出 CI 冒烟测试。v1 同样不在范围内的事项localstack_v11/v12/v13版本矩阵只针对最新 localstack 链以及 viem 变体——因为泄漏面核心 WASM 模块与客户端库无关而 ethers 正是 test/multi-wasm 已用于往返测试的库。七、适用前提与局限判定是趋势性的不是绝对值WASM 内存只增不减因此任何增长即失败的绝对判定都会误伤健康进程的热身阶段框架只认增速不衰减这一形态。进程级指标仅供参考rss等指标的锯齿噪声可能制造伪斜率即使增加窗口平滑也无法完全消除所以它们不参与门禁——但这不代表它们无价值真实运行中它们值得人工一瞥。环境要求需要可用的 localstack Docker 栈与真实 relayer纯本地场景permitChurn、providerChurn不依赖中继但clientReuse/clientChurn/roundtrip/valueChurn都会触达中继。v1 阈值未经验证在调优完成前任何场景被判growing都应结合原始 JSONL 数据人工复核而非直接当作最终结论。这套框架的价值在于把WASM 单例、内存只增不减、GC 终结器依赖这些易被忽视的约束显式编码进了测试设计对照组排除框架自身嫌疑隔离场景让每个泄漏面单独现形而门禁指标只交给最可靠的 WASM 线性内存信号。对于任何以 WASM 承载加密运算的 JS SDK这套长时稳态循环 两半斜率趋势检测 中位数窗口平滑的方法论都值得直接借鉴。【免费下载链接】fhevmFHEVM, a full-stack framework for integrating Fully Homomorphic Encryption (FHE) with blockchain applications项目地址: https://gitcode.com/GitHub_Trending/fh/fhevm创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考