Skip to content

perf(cli): avoid hashing invalid inline image cache hits - #10626

Open
zjunothing wants to merge 2 commits into
QwenLM:mainfrom
zjunothing:fix/issue-8608-negative-cache-follow-up
Open

perf(cli): avoid hashing invalid inline image cache hits#10626
zjunothing wants to merge 2 commits into
QwenLM:mainfrom
zjunothing:fix/issue-8608-negative-cache-follow-up

Conversation

@zjunothing

Copy link
Copy Markdown
Collaborator

What this PR does

This follow-up stores invalid inline-image payloads directly in a small negative LRU cache, rejects empty or oversized payloads before consulting either decode cache, and adds regression coverage that distinguishes resident hits, true eviction, and hit-based recency refresh.

Why it's needed

PR #8789 avoided repeated Base64 decoding for invalid inline images, but its negative-cache lookup still calculated SHA-256 over the entire payload on every render. On current main, an 8 MiB invalid payload took a median 2.758 ms per cached hit and performed 26 full-payload hash updates across 26 calls. This follow-up removes that repeated linear work while keeping invalid-payload retention explicitly bounded.

Reviewer Test Plan

How to verify

  1. Repeatedly prepare the same bounded, Base64-valid non-PNG payload. The first call should decode it once; later calls should neither decode it again nor calculate a full-payload SHA-256.
  2. Fill the negative cache with eight unique invalid payloads, touch the oldest entry, then insert a ninth. The touched entry should remain resident and the next-oldest entry should be evicted.
  3. Prepare an inline payload above the encoded-size limit. It should return the image fallback without entering Base64 decoding.

The focused renderer suite passes 31/31. A mutation check that removes the hit-path recency refresh makes the LRU test fail, confirming that the assertions observe the intended behavior rather than only the total insertion count.

Evidence (Before & After)

There is no UI output change. An independent probe using the same 8 MiB invalid payload measured the following:

Metric main This PR
Cached-hit median 2.758 ms 0.000834 ms
Full-payload SHA updates over 26 calls 26 0
Base64 decodes over 26 calls 1 1

Tested on

OS Status
🍏 macOS
🪟 Windows N/A
🐧 Linux N/A

Environment (optional)

Node.js 22.23.1 on macOS. The focused renderer suite, CLI build, CLI typecheck, full workspace and integration typecheck, ESLint, Prettier, and diff whitespace checks passed.

Risk & Scope

  • Main risk or tradeoff: Raw payload keys can retain their string backing storage for the cache lifetime. The bound is reduced from 64 hashes to eight strings; at the maximum encoded length, those entries can retain about 85.3 million ASCII characters in total before eviction.
  • Not validated / out of scope: Windows and Linux were not tested locally. The full workspace build stopped at an unchanged SDK browser-bundle size gate (221,287 bytes versus 221,184 allowed), and the adjacent component test is currently blocked during collection by the existing cli-boxes import-attribute parser error; the changed renderer suite and CLI build pass.
  • Breaking changes / migration notes: None.

Linked Issues

Follow-up to #8789 for #8608.

中文说明

这个 PR 做了什么

这个 follow-up 将无效的内联图片 payload 直接存入一个较小的负向 LRU 缓存,在查询任一解码缓存前拒绝空数据或超限数据,并补充了能够区分驻留命中、真实淘汰和命中后新近度刷新的回归测试。

为什么需要这个 PR

PR #8789 避免了无效内联图片被重复执行 Base64 解码,但它的负缓存查询仍会在每次渲染时对整个 payload 计算 SHA-256。在当前 main 上,一个 8 MiB 的无效 payload 每次缓存命中的中位耗时为 2.758 ms,26 次调用共执行 26 次完整 payload 哈希更新。这个 follow-up 去掉了这段重复的线性开销,同时继续明确限制无效 payload 的驻留数量。

Reviewer 测试计划

如何验证

  1. 重复准备同一个未超限、Base64 合法但不是 PNG 的 payload。第一次调用应只解码一次;后续调用既不应再次解码,也不应计算完整 payload 的 SHA-256。
  2. 用八个不同的无效 payload 填满负缓存,命中其中最旧的一项,再插入第九项。刚命中的项应继续驻留,次旧项应被淘汰。
  3. 准备一个超过编码长度上限的内联 payload。它应直接返回图片 fallback,且不进入 Base64 解码。

renderer 定向测试 31/31 通过。删除命中路径中新近度刷新的 mutation 检查会使 LRU 测试失败,证明这些断言确实观察到了目标行为,而不只是统计总插入次数。

证据(Before & After)

本次没有 UI 输出变化。独立探针使用同一个 8 MiB 无效 payload 得到以下结果:

指标 main 本 PR
缓存命中中位耗时 2.758 ms 0.000834 ms
26 次调用中的完整 payload SHA 更新次数 26 0
26 次调用中的 Base64 解码次数 1 1

测试平台

OS 状态
🍏 macOS
🪟 Windows N/A
🐧 Linux N/A

环境(可选)

macOS,Node.js 22.23.1。renderer 定向测试、CLI build、CLI typecheck、全 workspace 与 integration typecheck、ESLint、Prettier 和 diff 空白检查均通过。

风险与范围

  • 主要风险或权衡:原始 payload key 会在缓存存活期间保留对应字符串的底层存储。缓存上限从 64 个哈希降为八个字符串;按最大编码长度计算,这些条目在淘汰前合计可能保留约 8530 万个 ASCII 字符。
  • 未验证 / 不在范围内:本地未测试 Windows 和 Linux。全 workspace build 在未改动的 SDK 浏览器 bundle 大小门禁处停止(221,287 字节,限制为 221,184 字节);相邻组件测试目前也会在收集阶段被既有的 cli-boxes import attribute 解析错误阻塞,但本次改动的 renderer 测试和 CLI build 均通过。
  • 破坏性变更 / 迁移说明:无。

关联 Issue

这是 #8789 针对 #8608 的 follow-up。

@zjunothing

zjunothing commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Verification report

Result: PASS (VERIFIED_FIXED).

Environment: macOS, Node.js 22.23.1. Baseline: origin/main@954f3a92ce7fcf2485ac670f101891fbcd979db5. Fixed commit: 390f4e90200948c162967a5a8ed09ee7c2401781.

Reproduction and result

  • The independent probe used an 8 MiB Base64-valid non-PNG payload and instrumented full-payload SHA updates and Base64 decoding. On main, the first call took 19.389 ms, 25 cached hits had a 2.758 ms median, and 26 calls performed 26 full-payload SHA updates plus one decode.
  • With this PR, the first call took 15.415 ms, 25 cached hits had a 0.000834 ms median, and 26 calls performed zero full-payload SHA updates plus one decode. A separate 10,000-hit batch averaged 0.000160 ms per hit.
  • The previous cache test still passed when every negative lookup was forced to miss. The replacement test fills eight entries, refreshes the oldest, inserts a ninth, confirms the refreshed entry remains resident, and confirms the next-oldest entry is evicted.
  • A mutation probe that disabled the refresh made the refreshed payload decode twice and made the focused assertion fail. This confirms that the test observes LRU recency rather than only counting insertions.
  • An oversized payload returns before Base64 decoding.

Automated checks

  • Focused renderer tests: 31/31 passed.
  • CLI build: passed.
  • CLI typecheck: passed.
  • Full workspace and integration typecheck: passed.
  • ESLint, Prettier, and git diff --check: passed.

There is no visual output change, so screenshots are not applicable. The full workspace build stopped at an unchanged SDK browser-bundle size gate (221,287 bytes versus 221,184 allowed); all three currently red CI jobs stop at this same gate during dependency installation, before their test steps. The adjacent component test is also blocked before collection by the existing cli-boxes import-attribute parser error; none of these failures executes or points to the changed renderer path.

中文验证报告

验证报告

结果:PASS(VERIFIED_FIXED

环境:macOS,Node.js 22.23.1。基线:origin/main@954f3a92ce7fcf2485ac670f101891fbcd979db5。修复提交:390f4e90200948c162967a5a8ed09ee7c2401781

复现与结果

  • 独立探针使用一个 8 MiB、Base64 合法但不是 PNG 的 payload,并监测完整 payload SHA 更新次数和 Base64 解码次数。在 main 上,首次调用耗时 19.389 ms,25 次缓存命中的中位耗时为 2.758 ms,26 次调用执行了 26 次完整 payload SHA 更新和一次解码。
  • 应用本 PR 后,首次调用耗时 15.415 ms,25 次缓存命中的中位耗时为 0.000834 ms,26 次调用执行了零次完整 payload SHA 更新和一次解码。另一组 10,000 次命中的批量平均耗时为每次 0.000160 ms。
  • 旧缓存测试在强制所有负缓存查询 miss 时仍然通过。替换后的测试先填入八项,刷新最旧项,再插入第九项,确认被刷新的项仍驻留,并确认次旧项被淘汰。
  • 禁用刷新逻辑的 mutation 探针会让被刷新 payload 解码两次并使定向断言失败,证明测试观察到了 LRU 新近度,而不只是统计插入次数。
  • 超限 payload 会在 Base64 解码前直接返回。

自动化检查

  • renderer 定向测试:31/31 通过。
  • CLI build:通过。
  • CLI typecheck:通过。
  • 全 workspace 与 integration typecheck:通过。
  • ESLint、Prettier 和 git diff --check:通过。

本次没有可视输出变化,因此不适用截图。全 workspace build 在未改动的 SDK 浏览器 bundle 大小门禁处停止(221,287 字节,限制为 221,184 字节);当前三项红色 CI job 也都在依赖安装阶段遇到同一门禁,尚未进入测试步骤。相邻组件测试还会在收集前被既有的 cli-boxes import attribute 解析错误阻塞;这些失败均没有执行或指向本次改动的 renderer 路径。

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the follow-up!

Template looks good ✓

Problem: observed, not theoretical. I checked the code on main: getDecodedInlinePng() computes a SHA-256 over the entire payload before every negative-cache lookup, so a cached invalid payload still pays a full-payload hash on each re-render. The before/after probe in the description (2.758 ms median per cached hit, 26 hash updates over 26 calls on an 8 MiB payload) matches what the code plainly does. This is leftover linear work from #8789, which closed #8608 — a legitimate remaining delta, not a duplicate.

Direction: aligned. #8608 asked for exactly this class of fix (avoiding repeated work on inline images), and this finishes the job on the invalid-payload path the merged PR already introduced.

Size: not core infrastructure — both files live in packages/cli/src/ui/utils/. 41 production lines (+16/−25) and 75 test lines (+51/−24). Nothing for Stage 0 to flag.

Approach: the scope feels exactly right. Keying the negative cache by the raw payload string mirrors the positive decode cache in the same file, which is already keyed by raw string; shrinking the bound from 64 hashes to 8 strings compensates for the larger entries; and rejecting empty/oversized payloads before consulting either cache is an O(1) short-circuit that also keeps oversized payloads out of the cache entirely. No unrelated changes in the diff.

Risk: no elevated risk signals — neither file matches the revert-correlated paths.

Moving on to code review. 🔍

中文说明

感谢这个 follow-up!

模板完整 ✓

问题:是已观测到的问题,不是理论性的。我核对了 main 上的代码:getDecodedInlinePng() 在每次查询负缓存前都会对整个 payload 计算 SHA-256,因此已缓存的无效 payload 每次重渲染仍要付出一次完整哈希的开销。描述中的 before/after 探针(8 MiB payload 每次缓存命中中位 2.758 ms、26 次调用 26 次哈希)与代码行为一致。这是 #8789(关闭 #8608)遗留的线性开销,属于合理的剩余差距,不是重复 PR。

方向:对齐。#8608 要求的正是这类修复(避免内联图片的重复开销),本 PR 在已合并 PR 引入的无效 payload 路径上完成了收尾。

规模:不涉及核心基础设施——两个文件都在 packages/cli/src/ui/utils/。生产代码 41 行(+16/−25),测试 75 行(+51/−24)。Stage 0 无需标记。

方案:范围恰当。负缓存改用原始 payload 字符串作 key,与同文件中已用原始字符串作 key 的正向解码缓存保持一致;上限从 64 个哈希降为 8 个字符串以匹配更大的条目;在查询任何缓存前拒绝空/超限 payload,是 O(1) 的短路,也让超限数据完全不进缓存。diff 中没有无关改动。

风险:无升级风险信号——两个文件均不命中与 revert 相关的高风险路径。

进入代码审查 🔍

Qwen Code · qwen3.8-max

Reviewed at 390f4e90200948c162967a5a8ed09ee7c2401781 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review — no blockers. I formed my own proposal from the base code before reading the diff (key the negative cache by the raw payload string like the positive cache already does, shrink the bound, reject empty/oversized payloads before consulting anything), and this PR lands exactly on it:

  • The negative cache is now keyed by the raw payload string, mirroring inlineDecodeCache in the same file; the delete+add recency refresh is preserved, and the 64→8 limit is the right compensation now that entries are multi-MB strings instead of 64-byte hashes. The memory tradeoff is real but disclosed, and the positive cache already retains up to four raw payloads of the same size class.
  • Moving the empty/oversized rejection from decodeInlineImage up into getDecodedInlinePng is behavior-preserving — getDecodedInlinePng is the only caller — with the nice side effect that oversized payloads now skip both caches entirely instead of being hashed and stored.
  • Merging the two failure paths into if (!png || !size) and dropping the single-use cacheInvalidInlineImage helper keeps the eviction semantics identical while removing code.
  • The rewritten test is the strongest part: it spies on Buffer.from and crypto.createHash to distinguish resident hits, true eviction, and hit-based recency refresh — and expect(createHash).not.toHaveBeenCalled() would fail against the pre-PR code, so the suite actually pins the change rather than passing identically without it.
  • Blast radius checked: the only public-surface change is the exported constant (64→8), consumed only by this module and the parameterized test. TerminalImage.tsx and the dynamic import in config.ts use symbols this PR doesn't touch, and valid-image rendering (fallback text, sizing, kitty/chafa paths) is unchanged.

One non-blocking observation: the valid-image path still hashes the decoded buffer on every render in createInlineRenderCacheKey — the same class of cost this PR removes from the invalid path. Pre-existing and out of scope here, but worth a follow-up issue if anyone wants it.

Testing — the PR's own CI, quoted from the API (unattended run; no PR code executed here):

CI on the reviewed commit — all three red checks fail at the same pre-existing build gate, before any test executes:

Check Conclusion
Test (ubuntu-latest, Node 22.x) ❌ failure
Test (macos-latest, Node 22.x) ⏭️ skipped
Test (windows-latest, Node 22.x) ⏭️ skipped
Integration Tests (no-AK, No Sandbox) ❌ failure
Integration Tests (CLI, No Sandbox) ⏭️ skipped
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ❌ failure
Desktop Shell (ubuntu-22.04 / windows-2022) ✅ success
precheck-pr / precheck ✅ success
Secret scan (TruffleHog) ✅ success
Dependency CVE audit ✅ success

The red checks are pre-existing infra noise, not this PR: all three die in the build step with Error: Browser daemon SDK bundle is 221287 bytes; expected <= 221184 from assertBrowserSafeBundle in packages/sdk-typescript/scripts/build.js, before any test runs. The same job on the base commit (main@954f3a92, job 99450718825) fails with the byte-identical message — same bundle size, same limit — and this PR touches nothing in packages/sdk-typescript. That gate needs a fix on main (shrink the bundle or re-baseline the limit); this PR cannot influence it.

The consequence, stated plainly: no CI test results exist for this commit. Every test lane was blocked by that gate. The "focused renderer suite passes 31/31" line above is the author's local claim from the PR description — attributed as such, not verified evidence. Not verified: unit/integration/E2E results on the PR code (reason: pre-existing build gate on main blocks all test lanes).

Sandboxed verification would settle this: @qwen-code /verify — the no-hash hit path and the LRU eviction/refresh semantics are the central claims, and the only evidence for them in this thread is the author's own machine. The author lacks write access, so this would be a sponsored run: a maintainer's @qwen-code /verify comment approves the head it's written against, and that run carries a pre-execution risk screen plus a full workspace wipe — read its report with the same skepticism as the fork's own CI logs.

中文说明

代码审查——无阻塞问题。我在读 diff 前先基于 base 代码给出了自己的方案(负缓存改用原始 payload 字符串作 key、与正向缓存保持一致,收紧上限,在查询任何缓存前拒绝空/超限数据),本 PR 与该方案完全一致:

  • 负缓存改用原始字符串作 key,与同文件的 inlineDecodeCache 对称;delete+add 的新近度刷新保留;条目从 64 字节哈希变成可达数 MB 的字符串,上限相应从 64 降到 8,权衡合理,且作者在描述中明确披露了内存代价(正向缓存本就驻留四个同量级的原始 payload)。
  • 空/超限拒绝从 decodeInlineImage 上移到 getDecodedInlinePng,行为保持不变(后者是前者的唯一调用方),且超限数据现在完全不进入任何缓存。
  • 两个失败路径合并为 if (!png || !size),删除只用一次的 cacheInvalidInlineImage 辅助函数,淘汰语义不变。
  • 重写的测试是最强的部分:用 Buffer.fromcrypto.createHash 的 spy 区分驻留命中、真实淘汰与命中刷新;expect(createHash).not.toHaveBeenCalled() 在旧代码上必然失败,说明测试确实钉住了本次改动。
  • 影响面已核查:唯一的公开面变化是导出常量 64→8,仅本模块与其参数化测试使用;TerminalImage.tsxconfig.ts 的动态导入使用的符号均未被改动,合法图片的渲染路径不受影响。

非阻塞观察:合法图片路径的 createInlineRenderCacheKey 仍在每次渲染时对解码后的 buffer 计算哈希——与本 PR 消除的开销同类,属既有问题、不在本 PR 范围,如有需要可另开 issue。

测试——引用 PR 自身 CI(无人值守运行,此处未执行任何 PR 代码):三个红色检查全部在 build 步骤的既有门禁处失败(Browser daemon SDK bundle is 221287 bytes; expected <= 221184packages/sdk-typescript),未执行任何测试。已验证基线提交 main@954f3a92 的同一 job 以字节级相同的报错失败,且本 PR 完全未触及 packages/sdk-typescript——属于 main 上的既有基础设施问题,与本 PR 无关,需要单独修复(缩小 bundle 或调整限制)。

因此直说:本提交没有任何 CI 测试结果,所有测试通道都被该门禁挡住。描述中"定向测试 31/31 通过"是作者的本地声明,仅转述、不作为证据。未验证项:PR 代码的单测/集成/E2E 结果(原因:main 上的既有 build 门禁阻断了所有测试通道)。

可用沙箱验证收口:@qwen-code /verify——"命中不再哈希"与 LRU 淘汰/刷新语义是本 PR 的核心主张,目前唯一证据来自作者本机。作者无写权限,需要维护者发起 sponsored run(维护者评论即批准对应 head,运行前有执行前风险筛查与完整工作区清理);报告仍应以对待 fork CI 日志的审慎态度阅读。

Qwen Code · qwen3.8-max

Reviewed at 390f4e90200948c162967a5a8ed09ee7c2401781 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — minimal, correct change with a test that pins the behavior; one point withheld because CI never produced a test run for this commit (blocked by the pre-existing main gate) and the perf magnitude is the author's own measurement.

Stepping back: this is what a good follow-up PR looks like. It finishes the job #8608 asked for and #8789 started, deletes more code than it adds (the hash, the single-use helper), and its test would fail against the pre-PR implementation — expect(createHash).not.toHaveBeenCalled() is exactly the kind of assertion that proves a suite isn't passing identically with the change removed. My independent proposal before reading the diff — key by raw payload string like the positive cache, shrink the bound, reject before lookup — is precisely what landed, and I found no simpler path. The 64→8 bound with disclosed worst-case retention (~85M chars) is a fair trade for removing a per-render SHA-256 over megabyte payloads, and the positive cache already retains same-class payloads.

The honest reservation: my confidence is a static trace, not an observed green run. The paths are short and I verified every consumer of the changed surface, and the red CI is conclusively not this PR's doing (byte-identical SDK bundle-gate failure on the base commit, untouched package) — so none of it counts against the author. But it does mean merging waits on main's bundle-size gate being fixed and this branch rebasing onto it, at which point CI re-runs everything and this approval resets. A maintainer-triggered sponsored @qwen-code /verify can supply the test run CI couldn't, if wanted before merge.

Approving, pinned to the reviewed commit. ✅

中文说明

置信度:4/5 —— 改动最小且正确,测试能够钉住行为;扣一分是因为 CI 未能为本提交产出任何测试运行(被 main 上的既有门禁阻断),且性能数字来自作者自测。

整体来看:这是一个标准的高质量 follow-up。它完成了 #8608 提出、#8789 开始的收尾工作,删除的代码比新增的多(哈希计算、只用一次的辅助函数),且其测试在旧实现上必然失败——expect(createHash).not.toHaveBeenCalled() 正是能证明"测试并非在改动移除后照样通过"的那类断言。我在读 diff 前独立给出的方案(像正向缓存一样用原始字符串作 key、收紧上限、先拒绝再查询)与最终实现完全一致,也没有找到更简的路径。上限 64→8 并披露最坏驻留(约 8530 万字符)是合理权衡——去掉的是对数 MB payload 的逐次 SHA-256,而正向缓存本就驻留同量级数据。

如实保留一点:我的信心来自静态走查,而非一次观察到的绿色运行。路径很短、改动面的所有消费方均已核查,红色 CI 确定与本 PR 无关(基线提交上字节级相同的 SDK bundle 门禁失败、未触及该包),因此这些都不应记在作者头上。但这意味着合并需要先修复 main 的 bundle 大小门禁并在 rebase 后重跑 CI,届时本批准会按分支保护规则重置。如需在合并前补上 CI 未能提供的测试运行,维护者可发起 sponsored @qwen-code /verify

批准,并固定到所审查的提交。✅

Qwen Code · qwen3.8-max

Reviewed at 390f4e90200948c162967a5a8ed09ee7c2401781 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@qqqys

qqqys commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

E2E review report (no Critical found) — head 390f4e9020

Independent review of the diff found no Critical issue. The negative cache switches from sha256-of-payload keys to raw-payload keys exactly like the positive inlineDecodeCache already in this file; the 64→8 bound compensates for entries growing from 64-byte digests to multi-MB strings, and the worst case stays bounded (8 entries ≤ MAX_INLINE_IMAGE_ENCODED_LENGTH ≈ 10.7 MB each — the positive cache already retains up to 4 raw payloads of the same class). The empty/oversized guard moves from decodeInlineImage into getDecodedInlinePng — verified behavior-preserving: getDecodedInlinePng is the only caller of decodeInlineImage at head, and the guard now runs before any cache lookup, so oversized payloads skip both hashing and decoding. LRU delete+add recency refresh is preserved; crypto remains used by the kitty placeholder/render-cache paths (no unused import). The bot's triage reached the same construction independently (Stage 2: no blockers; Stage 3: 4/5, one point withheld only because CI never produced a test run for this commit).

Why CI is red (and not because of this PR)

All three failing lanes (Test, Integration Tests (no-AK, No Sandbox), web-shell E2E Smoke) die in the same pre-existing main gate during npm ci's prepare build:

Error: Browser daemon SDK bundle is 221287 bytes; expected <= 221184
    at assertBrowserSafeBundle (packages/sdk-typescript/scripts/build.js)

This PR touches only packages/cli/src/ui/utils/terminal-image-renderer.ts (+ its test) and cannot affect the SDK browser bundle; the gate went red on main between 3aa1b14624 (2026-08-30, where the same lanes passed, e.g. on #10522's head) and this PR's base 954f3a92ce. A rebase onto current main once the gate is fixed upstream should turn these lanes green.

Verification on the built head

Built head 390f4e9020 from source (npm ci + npm run bundle, with a scratch-tree-only relaxation of MAX_DAEMON_BROWSER_BUNDLE_BYTES past the pre-existing main gate; no PR files modified), then:

Unitpackages/cli: terminal-image-renderer.test.ts 31/31 pass, including the new bounded-LRU test (refreshed entry survives eviction; next-oldest evicted; createHash never called) and the pre-decode oversized rejection.

Probe against the shipped dist build (imported the compiled renderer; instrumented Buffer.from base64 decodes and full-payload sha256 updates):

Probe Result
Valid 1×1 PNG, kitty env ✅ rendered (kind=kitty, [image: 1x1 png])
Invalid payload, first miss ✅ fallback [image: png]; exactly 1 decode, 0 full-payload hashes
Same payload ×50 cached hits 0 extra decodes, 0 full-payload hashes, median ≈ 2.7 µs/hit
LRU bound (limit 8): 9 inserts, re-hit evicted entry, re-hit resident entry ✅ 9 decodes for 9 payloads; evicted entry decodes again (10); resident entry does not (stays 10)
Oversized payload (limit+1 chars) ✅ rejected before decode and before hashing (0/0)

tmux interactive-TUI smoke (bundled dist/cli.js, hermetic HOME + runtime dir, --yolo, real model qwen3.8-max): boots clean; a WriteFile → Read → Shell verification turn completes and renders correctly; /quit exits cleanly with a resumable session UUID and no orphaned processes.

Verdict

No Critical; the perf fix is real in the shipped bundle (cached invalid payloads no longer pay any full-payload hashing or decoding) and the LRU semantics are preserved. Not approving from this account — CI is red (pre-existing main gate, not this PR) and no bot/maintainer approval is on record; the 🧐 review lane was still running at the time of this check with no comments posted. Once main's bundle gate is green again this should re-CI cleanly.

— automated e2e pass by qqqys (probe + tmux on the built head); no code changes.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed. Suggestions are inline.

Not reviewed: build-and-test — Test (macos-latest, Node 22.x), Test (windows-latest, Node 22.x) and Integration Tests (CLI, No Sandbox) were skipped in CI, and the full packages/cli suite never reached its test phase locally because the pre-existing packages/sdk-typescript bundle-size gate failure stops the dependency build; the changed renderer suite itself was run directly and passed 30/31 with one pre-existing environmental failure (fake-chafa ESM shim).

Not explored to full depth (tool budget reached): "agent 3a": none — no checks were cut short..

中文说明

仅完成部分审查,审查缺口已披露。 建议见行内评论。

未审查:build-and-test — Test (macos-latest, Node 22.x), Test (windows-latest, Node 22.x) and Integration Tests (CLI, No Sandbox) were skipped in CI, and the full packages/cli suite never reached its test phase locally because the pre-existing packages/sdk-typescript bundle-size gate failure stops the dependency build; the changed renderer suite itself was run directly and passed 30/31 with one pre-existing environmental failure (fake-chafa ESM shim)。

未探索到全部深度(达到工具调用预算):"agent 3a"none — no checks were cut short.

— qwen3.8-max via Qwen Code /review (v0.22.3)

Comment on lines +204 to +206
).toEqual({ fallbackText: '[image: png]', result: null });
expect(bufferFrom).not.toHaveBeenCalled();
} finally {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-1: The expect(bufferFrom).not.toHaveBeenCalled() assertion added here passes identically against the pre-change code — the old size guard inside decodeInlineImage already ran before any Buffer.from call — so it pins nothing about the actual behavioral change of this hunk: oversized payloads now return before the negative-cache lookup and are never hashed or negatively cached. Pre-change, getDecodedInlinePng computed crypto.createHash('sha256') over every payload — oversized ones included — before the size check ran, and negatively cached the result. If a future edit moves the length check back below the cache lookups, every test in this file stays green while every oversized call again hashes an up-to-~11 MB string and occupies one of the 8 negative-cache slots, evicting useful entries — silently resurrecting the exact cost this PR removes. Spy on createHash here (as the LRU test above already does) and assert it is not called.

Witness:

BASE (pre-change source + this PR's test incl. the bufferFrom assertion): "Tests 1 passed" — the mutation survives the shipped assertion
Pre-change source + proposed expect(createHash).not.toHaveBeenCalled(): "1 failed" at that assertion
PR source + proposed assertion: "1 passed"

The fix spans three spots (spy setup, assertion, restore):

it('rejects inline payloads above the shared image limit before decoding', () => {
  const oversizedBase64 = 'A'.repeat(MAX_INLINE_IMAGE_ENCODED_LENGTH + 1);
  const bufferFrom = vi.spyOn(Buffer, 'from');
  const createHash = vi.spyOn(crypto, 'createHash');

  try {
    expect(
      prepareInlineTerminalImage({
        data: oversizedBase64,
        mimeType: 'image/png',
        contentWidth: 24,
        env: { TERM: 'xterm-kitty' },
        stdoutIsTTY: true,
      }),
    ).toEqual({ fallbackText: '[image: png]', result: null });
    expect(bufferFrom).not.toHaveBeenCalled();
    expect(createHash).not.toHaveBeenCalled();
  } finally {
    bufferFrom.mockRestore();
    createHash.mockRestore();
  }
});

Keep the assertion scoped to this oversized/invalid flow: crypto.createHash is still legitimately invoked on the successful-render path — createInlineRenderCacheKey (terminal-image-renderer.ts:334) and createImageId (:541) — which this test never reaches. Once the fix is in, removing it must turn rejects inline payloads above the shared image limit before decoding red — verify by moving the length check back into decodeInlineImage and confirming the new assertion fails while the existing bufferFrom one still passes.

中文说明

此处新增的 expect(bufferFrom).not.toHaveBeenCalled() 断言在改动前的代码上同样通过——旧的大小检查位于 decodeInlineImage 内部、任何 Buffer.from 调用之前——因此它并没有钉住本处真正的行为变化:超限 payload 现在会在查询负缓存之前直接返回,既不会被哈希,也不会进入负缓存。改动前,getDecodedInlinePng 会在大小检查运行前对每个 payload(包括超限的)计算 crypto.createHash('sha256'),并将哈希结果存入负缓存。如果未来的修改把长度检查移回缓存查询之后,本文件所有测试仍会全绿,而每次超限调用又会重新哈希一个最长约 11 MB 的字符串并占用 8 个负缓存槽位之一、挤掉有用条目——悄悄复活本 PR 要消除的开销。请在此处对 createHash 打桩(上方 LRU 测试已有同样写法)并断言它未被调用。

探针证据(临时树中四组对照):改动前源码 + 本 PR 测试(含 bufferFrom 断言):"Tests 1 passed"——该 mutation 在现有断言下存活;改动前源码 + 建议新增的 expect(createHash).not.toHaveBeenCalled():"1 failed",正失败在该断言上;本 PR 源码 + 建议断言:"1 passed"。建议的断言在本次改动前后发生红/绿翻转,而现有断言不翻转。

修复涉及三处(打桩、断言、恢复),完整代码见上方英文部分的代码块。

约束:该断言须保持在超限/无效流程内——crypto.createHash 在渲染成功路径上仍被正常使用(createInlineRenderCacheKeyterminal-image-renderer.ts:334createImageId:541),本测试不会走到这些路径。修复落地后,若将其移除,rejects inline payloads above the shared image limit before decoding 必须变红——可将长度检查移回 decodeInlineImage 验证:新断言失败,而现有 bufferFrom 断言仍通过。

— qwen3.8-max via Qwen Code /review (v0.22.3)

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline.

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • The oversized test's unpinned guard-ordering assertion (agent 5, probe-confirmed this round) — same defect as the still-standing R1-1, merged into its re-post — already reported (comment 3894525255)

Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:

  • packages/cli/src/ui/utils/terminal-image-renderer.ts:393 — [probe] byte-bound the raw-keyed negative cache (~89 MB worst-case retention)
  • packages/cli/src/ui/utils/terminal-image-renderer.ts:54 — [review] document the coupling between the 8-entry limit and raw-key size
中文说明

已审查。 建议见行内评论。

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.22.3)

Comment on lines +205 to +206
expect(bufferFrom).not.toHaveBeenCalled();
} finally {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-1: Still stands — re-reported from round 1 (the branch code is unchanged since; only a merge from main landed). The expect(bufferFrom).not.toHaveBeenCalled() assertion added here passes identically against the pre-change code — the old size guard inside decodeInlineImage already ran before any Buffer.from call — so it pins nothing about the actual behavioral change of this hunk: oversized payloads now return before the negative-cache lookup and are never hashed or negatively cached. If a future edit moves the length check back below the cache lookups, every test in this file stays green while every oversized call again hashes an up-to-~11 MB string and occupies one of the 8 negative-cache slots, evicting useful entries — silently resurrecting the exact cost this PR removes. This round's probe re-confirmed the mechanism at the reviewed commit: moving the guard back into decodeInlineImage keeps the full suite green while the oversized payload again flows through both cache lookups and evicts a resident entry.

Witness:

MUTANT (guard moved back below the cache lookups): Tests 31 passed (31)
PROBE-GUARD-ORDER callsAfterFill=8 callsAfterOversized=8 callsAfterResubmit=9 junk0-redecoded=true
INTACT (PR code): PROBE-GUARD-ORDER callsAfterResubmit=8 junk0-redecoded=false

Suggested fix (unchanged from round 1): spy on createHash here (as the LRU test above already does) and assert it is not called — that assertion flips red/green across the change, unlike the bufferFrom one:

it('rejects inline payloads above the shared image limit before decoding', () => {
  const oversizedBase64 = 'A'.repeat(MAX_INLINE_IMAGE_ENCODED_LENGTH + 1);
  const bufferFrom = vi.spyOn(Buffer, 'from');
  const createHash = vi.spyOn(crypto, 'createHash');

  try {
    expect(
      prepareInlineTerminalImage({
        data: oversizedBase64,
        mimeType: 'image/png',
        contentWidth: 24,
        env: { TERM: 'xterm-kitty' },
        stdoutIsTTY: true,
      }),
    ).toEqual({ fallbackText: '[image: png]', result: null });
    expect(bufferFrom).not.toHaveBeenCalled();
    expect(createHash).not.toHaveBeenCalled();
  } finally {
    bufferFrom.mockRestore();
    createHash.mockRestore();
  }
});

Keep the assertion scoped to this oversized/invalid flow — crypto.createHash is still legitimately invoked on the successful-render path by createInlineRenderCacheKey (terminal-image-renderer.ts:334) and createImageId (:541), which this test never reaches. Once the fix is in, removing it must turn this test red: move the length check back into decodeInlineImage and confirm the new createHash assertion fails while the existing bufferFrom one still passes.

中文说明

(仍然成立 — 自第 1 轮起重新报告;此后分支代码未变,仅合并了 main。)此处新增的 expect(bufferFrom).not.toHaveBeenCalled() 断言在改动前的代码上同样通过——旧的大小检查位于 decodeInlineImage 内部、任何 Buffer.from 调用之前——因此它并没有钉住本处真正的行为变化:超限 payload 现在会在查询负缓存之前直接返回,既不会被哈希,也不会进入负缓存。如果未来的修改把长度检查移回缓存查询之后,本文件所有测试仍会全绿,而每次超限调用又会重新哈希一个最长约 11 MB 的字符串并占用 8 个负缓存槽位之一,挤掉有用条目——悄悄复活本 PR 要消除的开销。本轮探针在受审提交上复证了该机制:把长度检查移回 decodeInlineImage 后,整个测试套件仍然全绿,而超限 payload 又会流经两处缓存查询并挤掉驻留条目。

探针证据:MUTANT(长度检查移回缓存查询之后):Tests 31 passed (31),callsAfterResubmit=9 junk0-redecoded=true;INTACT(本 PR 代码):callsAfterResubmit=8 junk0-redecoded=false。

建议的修复(与第 1 轮相同):在此处对 createHash 打桩(上方 LRU 测试已有同样写法)并断言它未被调用——该断言会在改动前后红/绿翻转,而现有 bufferFrom 断言不会。完整代码见上方英文部分的代码块。

约束:该断言须保持在超限/无效流程内——crypto.createHash 在渲染成功路径上仍被 createInlineRenderCacheKey(terminal-image-renderer.ts:334)和 createImageId(:541)正常使用,本测试不会走到这些路径。修复落地后,若将其移除,本测试必须变红:可将长度检查移回 decodeInlineImage 验证——新的 createHash 断言失败,而现有 bufferFrom 断言仍通过。

— qwen3.8-max via Qwen Code /review (v0.22.3)

@wenshao

wenshao commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Local verification — the headline claim reproduces; one trade-off worth an explicit decision

Verdict: the perf win is real and larger than claimed on this machine. No correctness defect found. But the 64 → 8 bound moves a cache cliff from 65 distinct invalid images down to 9, and past that cliff this PR is ~11× slower per image than main — reachable through the real component. I would settle that trade-off before merging; a byte-bounded cache (precedent already in this file) gets both halves.

Environment

macOS (darwin 25.6.0), Node v24.18.1. main@61697df9b0 vs PR head 141e2fbe47, two separate worktrees, both fully built (npm run build). Measurements run against the real renderer module, bundled straight from its TypeScript source with esbuild (real MAX_TERMINAL_IMAGE_BYTES, real MAX_INLINE_IMAGE_ENCODED_LENGTH), with crypto.createHash and Buffer.from(…, 'base64') instrumented before the module loads.

1. The author's claim reproduces — and is understated here

probe A/B

One 8 MiB Base64-valid non-PNG payload, 26 calls:

main this PR
cached-hit median 5.0990 ms 0.0015 ms
full-payload SHA-256 updates 26 0
bytes hashed over 26 calls 290,805,112 0
base64 decodes 1 1

Also confirmed for the oversized path, which the PR body does not quantify: on main an over-limit payload pays a full 11,184,816-char SHA-256 on every call (5.137 ms); on this PR it returns in 0.7 µs with zero hashes. That is a genuine second win.

2. New finding — the 64 → 8 bound moves the thrash cliff from N=65 to N=9

The negative cache is an LRU walked in insertion order, so a render pass that touches N distinct invalid payloads with N > limit misses on every entry (classic sequential-scan thrash). main's limit of 64 kept that cliff far away; 8 brings it within reach.

N distinct invalid 1 MiB payloads, 6 re-render passes, fresh process per N:

N main per-pass PR per-pass main decodes PR decodes
1 0.62 ms 0.00 ms 0 0
4 2.55 ms 0.01 ms 0 0
8 5.14 ms 0.01 ms 0 0
9 5.73 ms 66.32 ms 0 54
12 7.57 ms 87.20 ms 0 72
16 10.69 ms 117.56 ms 0 96
32 20.11 ms 232.70 ms 0 192
64 40.46 ms 465.37 ms 0 384
65 510.72 ms 473.40 ms 390 390
80 635.19 ms 580.31 ms 480 480

For 9 ≤ N ≤ 64 this PR is ~11.5× slower per image than main, because main still hits its 64-entry cache (paying one hash) while this PR misses and pays a full replace(/\s/g,'') + Buffer.from + toString('base64') round-trip. At N ≥ 65 both thrash and the PR is marginally ahead again.

3. Is N ≥ 9 reachable? Yes — measured through the real component

component A/B

Driving the actual <TerminalImage> React component with the actual renderer (ink-testing-library), changing contentWidth 80 → 72 — i.e. one terminal resize, which is exactly what the comment at the top of this file describes ("A terminal resize or a restored session re-renders every visible image"):

images on screen main resize PR resize main decodes PR decodes
4 (256 KiB each) 7.27 ms 5.36 ms 0 0
8 7.71 ms 5.85 ms 0 0
9 7.38 ms 12.30 ms 0 9
12 8.77 ms 14.17 ms 0 12
16 10.50 ms 16.86 ms 0 16
8 (2 MiB each) 18.09 ms 6.32 ms 0 0
12 (2 MiB each) 21.69 ms 64.63 ms 0 12

The useMemo in InlineTerminalImage is keyed on contentWidth, so every visible image is re-prepared on a resize. MAX_INLINE_IMAGES_PER_ITEM = 4, so three tool results carrying invalid images already exceed the new bound of 8, and there is no cap on how many such history items a session accumulates. At 2 MiB payloads that is +43 ms on every resize at N=12, against −12 ms at N=8.

I am not claiming this is common — a session with ≥9 distinct invalid inline images is a corner. But so is the ≤8 case the PR optimises, and the bound change is what decides which corner you land in.

4. Retention — the disclosed trade-off, measured

Worth flagging how it must be measured: Buffer.toString('base64') at these sizes yields an external string, so it does not appear in process.memoryUsage().heapUsed or in any v8.getHeapSpaceStatistics() space at all — a heap-based measurement reads 0 MB and is simply wrong. Using steady-state RSS over 40 distinct 8 MiB invalid payloads (caller references dropped each iteration, 3 forced GCs), 3 runs each:

main this PR
steady-state RSS growth 171.4 / 190.1 / 171.5 MB 235.3 / 235.4 / 235.4 MB

≈ +45–64 MB for the PR, consistent with retaining 8 × ~10.7 MiB of raw payload minus allocator noise, and with the ~85.3 M-char bound the PR discloses. One mitigating fact the description does not mention: those strings are usually the same objects the conversation history already holds, so the marginal retention only materialises after the owning history item is dropped.

5. Test strength

  • PR code + PR tests: 31 passed.
  • Counterfactual (main code + PR tests): 1 failed / 30 passed. The single failing assertion is expect(createHash).not.toHaveBeenCalled() (68 calls on main) — that one assertion carries the whole change.
  • Mutation matrix (6 mutants, PR tests):
mutant result
N1 drop the hit-path recency refresh killed
N2 move the empty/oversize guard back below the cache lookups SURVIVED
N3 never evict (unbounded negative cache) killed
N4 never populate the negative cache killed
N5 key the negative cache by a 64-char prefix SURVIVED
N6 restore the sha256 negative key (undo the perf change) killed

N2 confirms the bot's standing R1-1 with a mutation. The hunk that hoists the empty/oversize guard above the cache lookups can be reverted and the whole suite stays green, because expect(bufferFrom).not.toHaveBeenCalled() in the oversize test also passes on main (the old size check inside decodeInlineImage already ran before any Buffer.from). A createHash spy in that test — the same assertion that makes the LRU test load-bearing — closes it, and section 1 shows exactly what it would pin (11,184,816 chars hashed per call).

N5 is lower-stakes but shows the suite never pins the key identity: a 64-char-prefix key (which would collide across distinct payloads sharing a prefix) passes unchanged.

Suggestion for the bound

The file already has the pattern for this: renderCache is bounded by bytes (RENDER_CACHE_BYTE_LIMIT = 32 MiB), not by count, precisely because its entries vary hugely in size. Now that the negative cache holds raw payload strings instead of fixed 64-byte digests, the same treatment fits it better than any single count:

  • a byte budget keeps the worst-case retention explicit (the thing the PR flags as its main risk), and
  • it lets the small-payload case keep many more entries than 8, which is what removes the N ≥ 9 cliff — a 32 MiB budget holds ~90 payloads of 256 KiB but only 3 at the 10.7 MiB cap.

If a count is preferred for simplicity, keeping it at 64 costs nothing in the ≤8 case measured above (that case never evicts) and preserves main's cliff position; only the retention bound moves. Either way this is a design call for the maintainer, not a defect in the diff.

Not covered

  • macOS only; no Linux/Windows run. Nothing in the change is platform-specific.
  • I drove the renderer module and the <TerminalImage> component directly. I did not drive a full TUI session end-to-end with a model producing invalid inline images, so "≥9 distinct invalid images in one session" is argued from MAX_INLINE_IMAGES_PER_ITEM = 4 plus unbounded history items, not observed in a live session.
  • Absolute timings are one machine; the ratios and the decode/hash counts are the load-bearing numbers.
中文说明

本地验证 —— 主张成立;但有一个取舍值得先明确决定

结论:性能收益真实,在本机上甚至比声称的更大。未发现正确性缺陷。但 64 → 8 这个上限把缓存悬崖从「65 张不同的无效图」拉到了「9 张」,越过悬崖后本 PR 每张图比 main 慢约 11 倍,而且这一区间通过真实组件是可达的。我建议在合并前把这个取舍定下来;改成按字节限额(本文件里已有先例)可以两头都拿到。

环境

macOS(darwin 25.6.0),Node v24.18.1。main@61697df9b0 对比 PR head 141e2fbe47,两棵独立 worktree,均完整构建(npm run build)。测量针对真实的 renderer 模块,用 esbuild 直接从 TypeScript 源打包(真实的 MAX_TERMINAL_IMAGE_BYTESMAX_INLINE_IMAGE_ENCODED_LENGTH),并在模块加载前对 crypto.createHashBuffer.from(…, 'base64') 插桩。

1. 作者的主张复现 —— 而且这里比声称的还要好

一个 8 MiB、Base64 合法但非 PNG 的 payload,26 次调用:

main 本 PR
缓存命中中位耗时 5.0990 ms 0.0015 ms
完整 payload 的 SHA-256 update 次数 26 0
26 次调用累计哈希字节数 290,805,112 0
base64 解码次数 1 1

另外还确认了 PR 正文未量化的超限路径:在 main 上,一个超限 payload 每次调用都要对 11,184,816 个字符做一次完整 SHA-256(5.137 ms);本 PR 则 0.7 µs 返回、零哈希。这是实实在在的第二项收益。

2. 新发现 —— 64 → 8 把抖动悬崖从 N=65 挪到了 N=9

负缓存是按插入顺序淘汰的 LRU,因此一轮渲染若触及 N 个不同的无效 payload 且 N > 上限,就会每一项都未命中(典型的顺序扫描抖动)。main 的 64 让这个悬崖离得很远;8 把它拉进了可达范围。

N 个不同的 1 MiB 无效 payload,6 轮重渲染,每个 N 用全新进程:

N main 每轮 本 PR 每轮 main 解码 PR 解码
1 0.62 ms 0.00 ms 0 0
4 2.55 ms 0.01 ms 0 0
8 5.14 ms 0.01 ms 0 0
9 5.73 ms 66.32 ms 0 54
12 7.57 ms 87.20 ms 0 72
16 10.69 ms 117.56 ms 0 96
32 20.11 ms 232.70 ms 0 192
64 40.46 ms 465.37 ms 0 384
65 510.72 ms 473.40 ms 390 390
80 635.19 ms 580.31 ms 480 480

9 ≤ N ≤ 64 区间,本 PR 每张图比 main 慢约 11.5 倍main 仍命中它 64 项的缓存(只付一次哈希),而本 PR 未命中,要付完整的 replace(/\s/g,'') + Buffer.from + toString('base64') 往返。N ≥ 65 时两边都抖动,本 PR 略微领先。

3. N ≥ 9 可达吗?可达 —— 通过真实组件实测

用 ink-testing-library 驱动真实的 <TerminalImage> React 组件与真实 renderer,把 contentWidth 从 80 改到 72 —— 也就是一次终端 resize,正是本文件顶部注释所描述的场景("终端 resize 或恢复会话会重新渲染每一张可见图片"):

屏上图片数 main resize 本 PR resize main 解码 PR 解码
4(各 256 KiB) 7.27 ms 5.36 ms 0 0
8 7.71 ms 5.85 ms 0 0
9 7.38 ms 12.30 ms 0 9
12 8.77 ms 14.17 ms 0 12
16 10.50 ms 16.86 ms 0 16
8(各 2 MiB) 18.09 ms 6.32 ms 0 0
12(各 2 MiB) 21.69 ms 64.63 ms 0 12

InlineTerminalImageuseMemo 依赖 contentWidth,所以一次 resize 会让每张可见图片重新走一遍 prepare。MAX_INLINE_IMAGES_PER_ITEM = 4,因此三条携带无效图片的工具结果就已经超过新的上限 8,而会话能累积多少这样的历史项并没有上限。在 2 MiB payload 下,N=12 时每次 resize 多花 43 ms,而 N=8 时省下 12 ms。

我并不主张这很常见 —— 一个会话里出现 ≥9 张不同的无效内联图片属于边角情形。但本 PR 优化的 ≤8 情形同样是边角,而这次上限改动决定了你落在哪个边角里。

4. 驻留内存 —— 已披露的取舍,实测

先说测量方法上的坑:这种尺寸下 Buffer.toString('base64') 产生的是外部字符串,它既不出现在 process.memoryUsage().heapUsed,也不出现在 v8.getHeapSpaceStatistics() 的任何一个 space 里 —— 基于堆的测量会读到 0 MB,纯属错误。改用稳态 RSS:40 个不同的 8 MiB 无效 payload(每轮丢弃调用方引用,3 次强制 GC),每臂 3 次运行:

main 本 PR
稳态 RSS 增长 171.4 / 190.1 / 171.5 MB 235.3 / 235.4 / 235.4 MB

本 PR 约多 45–64 MB,与「驻留 8 × ~10.7 MiB 原始 payload 再减去分配器噪声」吻合,也与 PR 自己披露的约 8530 万字符上限吻合。有一点描述里没提,对本 PR 有利:这些字符串通常与会话历史持有的是同一批对象,所以边际驻留只有在对应历史项被丢弃之后才真正产生。

5. 测试强度

  • PR 代码 + PR 测试:31 passed
  • 反事实(main 代码 + PR 测试):1 failed / 30 passed。唯一失败的断言是 expect(createHash).not.toHaveBeenCalled()(在 main 上被调用 68 次)—— 整个改动靠这一条断言承重。
  • 变异矩阵(6 个变异体,跑 PR 测试):
变异体 结果
N1 删掉命中路径的新近度刷新 杀死
N2 把空值/超限守卫移回缓存查询之下 存活
N3 永不淘汰(负缓存无界) 杀死
N4 从不写入负缓存 杀死
N5 用 64 字符前缀作为负缓存 key 存活
N6 恢复 sha256 负缓存 key(撤销性能改动) 杀死

N2 用变异实证了 bot 那条未决的 R1-1。 把空值/超限守卫提升到缓存查询之上的那个 hunk 可以整个还原,而整套测试依然全绿 —— 因为超限测试里的 expect(bufferFrom).not.toHaveBeenCalled()main 上同样通过(旧的长度检查在 decodeInlineImage 内部,本来就跑在任何 Buffer.from 之前)。在那个测试里加一个 createHash spy(和让 LRU 测试承重的是同一类断言)即可闭合,而第 1 节正好给出了它会钉住什么(每次调用哈希 11,184,816 个字符)。

N5 影响较小,但说明测试从未钉住 key 的身份:换成 64 字符前缀 key(不同 payload 若共享前缀就会碰撞)依旧全绿。

关于上限的建议

本文件里已有现成的做法:renderCache 是按字节限额的(RENDER_CACHE_BYTE_LIMIT = 32 MiB)而不是按条数,正是因为它的条目大小差异极大。既然负缓存现在存的是原始 payload 字符串而不再是固定 64 字节的摘要,同样的处理比任何单一条数都更合适:

  • 字节预算把最坏情况的驻留量写死(这正是 PR 自己标记的主要风险),并且
  • 它让小 payload 的场景能保留远多于 8 条的条目,从而消除 N ≥ 9 的悬崖 —— 32 MiB 预算能装约 90 个 256 KiB 的 payload,但在 10.7 MiB 上限时只能装 3 个。

如果为了简单更倾向于用条数,那么维持 64 在上文实测的 ≤8 场景里没有任何代价(该场景根本不会触发淘汰),同时保住 main 的悬崖位置;变化的只有驻留上限。无论哪种,这都是维护者的设计决策,而不是 diff 里的缺陷。

未覆盖

  • 仅 macOS;未跑 Linux/Windows。改动本身没有平台相关内容。
  • 我直接驱动的是 renderer 模块和 <TerminalImage> 组件,没有端到端驱动一个由模型产出无效内联图片的完整 TUI 会话;因此"一个会话里 ≥9 张不同的无效图片"是由 MAX_INLINE_IMAGES_PER_ITEM = 4 加上历史项无上限推导出来的,不是在真实会话里观察到的。
  • 绝对耗时来自单台机器;真正承重的是比值以及解码/哈希次数。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants