Repository navigation
fix: 修复 #98 审查发现的图片卸载顺序、目录来源与监听泄漏问题(含其全部提交) - #101
Merged
Merged
Conversation
streamAttempt() 原本严格串行:先等完 resolveApiKey()(账号池选择、凭据读取, 必要时还有探测),再从零开始读图并装配正文。两者其实互不依赖 —— 正文从不携带 密钥,健康账号池的解析也只读内存 —— 所以它们的等待时间一直是相加而非重叠的。 带图请求尤其明显:readImage.prepare() 的图片读取与 JSON 装配全部排在凭据之后。 现在把与密钥无关的通道判断(强制 cli、只有 messages 端点服务的模型、Provider API 默认值)先算成 provisionalProtocol,并让 buildBody() 与 resolveApiKey() 并发; 凭据返回后再调用真正的 resolveProtocol(),目标一致就直接复用已装配的正文,不一致 才重建一次(与既有的协议回退、账号轮换同价)。 线上字节不变: - 首次装配发生在任何账号切换之前,budget.max 仍等于初始值,所以 maxTokens 与原 实现一致;凭据等待期间若别的请求学到更低的上限,会补写进正文并同步收紧 budget.max。 - 图片预算那条 withSurfaceOffload() 仍先于任何 I/O 执行,超额仍在读图和解析凭据 之前失败;只有「已看过的图片」这一 CLI 专属缓存项需要真实通道,因此在拿到密钥 后按真实通道再判一次(只可能抛错,不会改变档位)。 - 凭据解析之后仍保留 throwIfAborted(),取消语义不变。 新增回归用「凭据尚未返回时图片读取已经开始」钉住重叠,不依赖墙钟时间:回退到串行 实现时该用例必然失败。
listModels() 每次打开都无条件 await gatewayFacts.refresh():模型选择器和设置页 的过滤编辑器都只是把目录画出来,却因此要等一次完整的网关往返,才能画出本进程 手里早已有、而且刚刚还准确的同一批行。 现在只要本进程已经加载过该网关的目录,就用内存里的行立即返回,并把刷新放到后台 (不 await、吞掉可能的拒绝),好让下次打开拿到新行。进程首次加载仍然照旧等联网: 没有诚实的行可画,而过滤编辑器确实需要真实列表。 新判据 GatewayFacts.loaded() 刻意与 snapshot() 区分:后者会读磁盘,而磁盘缓存 没有年龄校验,拿它当首屏会让冷启动显示一份不知道多久以前的目录。loaded() 只读 内存 —— 要么刚联网取回,要么本进程刚刚刷新成功 —— 因此可以安全地先画再更新。 resolveModel() 与 stream() 的路径不变(仍 await 真实刷新),目录 TTL 与发布代 序号语义也不变:refresh() 在有效期内本就是内存命中,所以常见路径没有新开销。 新增回归让后台刷新故意晚一步:若本次调用去等它,拿到的会是新行而不是旧行。 把实现改回无条件 await 时该用例失败。
gradeHeaderTimeout() 判断「这次超时是不是刚刚那份载荷」时调用 fingerprintValue(body),而它内部又做一次 JSON.stringify —— 正文在发出去之前 已经序列化过一遍了。带图历史可达数 MB,而连续超时恰恰是最常重复的路径,等于每次 重试都为同一个答案多走一遍整棵 body。 现在把实发字符串直接传给定级函数,用 sha256Hex() 对已经序列化好的文本求摘要: 只哈希字节,不再 stringify。这同时更准确 —— 实发字符串就是网关看到的字节, 而重新 stringify 会在 surrogate 清洗真正删掉过字符时描述到不同的字节,把一次连续 超时拆成两条 streak。序列化那份字符串仍由 serializedBodies 持有,正文重建或预算 下调时照旧 delete,所以指纹仍恰好在线上字节改变时改变。 没有实发字符串时(正文从未上过线)保留对象哈希作为兜底。 新增回归用 toJSON 计数器钉住遍历次数:回退到重新 stringify 时该用例失败。
每个传输通道在每次尝试时都会重建工具列表,而账号轮换、协议回退、以及降档重试都 会让同一批工具再走一遍。根节点归一化只读参数对象本身,答案不会因为问了第二次而 改变,但从前每次都要重新判一遍:读 type、判 $ref、展开组合子、必要时再克隆。 现在按 parameters 的对象身份用 WeakMap 记住根节点结果。工具对象来自宿主,在整个 回合里是稳定的,所以 identity 就是精确的键:真正不同的 schema 是另一个对象,自然 不命中。只有根节点记忆化;嵌套 $ref / 组合子分支仍走 normalizeToolParameters(), 不在热路径上。 同时纠正一处与实现对不上的注释:原注释声称「每条路径都返回副本」,但已有 type: 'object' 的快路径一直返回调用方自己的对象 —— 这也是既有行为,调用方靠它拿到 同一个已冻结的 schema。注释现在如实说明只改根节点、构造出来的对象都是新的。 新增回归用 params.type 的 getter 计数钉住「只归一化一次」,并复查每次尝试的线缆上 仍带着归一化后的根节点。去掉记忆化时该用例失败。
设置卡片的账户区与选择器的套餐过滤读的是同一批端点、同一个账号:whoami 一次, 再并行 credits + subscriptions 一次。两处各自独立读取,等于同一份答案付两遍钱; 而卡片还在定时刷新,于是选择器会反复为它早就拿到过的行再付一次往返。 现在把成功的账户端点响应按「网关 + 密钥 + 完整 URL」记进一张短命表 (ACCOUNT_FACTS_TTL_MS,30 秒),两条路径共用。选择器只缓存自己那套 BILLING_ACCESS_TTL_MS,所以卡片的即时结果不会绕过它自己的 5 分钟语义。 刻意只缓存成功响应:失败必须重试,否则刚刚恢复的端点会继续报告昨天的故障,报告的 failures 与 classifyTotalFailure 结论还会描述一次本进程没真正发出的请求。 usage/summary 明确不参与共享(share=false):那是用户正盯着的数字,卡片旁边写着 读取时间,回放一次早先的响应会让数字与时间同时对不上。身份与套餐事实相反 —— 30 秒 前的 org id 或套餐档位仍然是事实,而它们才正是选择器重复读取的部分。 把密钥校验(usableAccountKey)与请求头构造(accountHeadersFor)拆开,使两条路径都 能只解析一次密钥就用同一个身份去查表。 新增回归钉住三点:选择器复用卡片的 whoami/subscriptions;用量汇总每次都真的重读; 把共享关掉时该用例失败。
一份用量报告是四次上游调用,而多个界面会在无人操作的情况下各自发起读取:设置页 账户卡片与面板都在首屏取一次,重挂载或重渲染还会让同一批 effect 再跑一遍。这是 同一账号、同一时刻的同一份答案,付两遍钱买不到任何东西。 现在 refresh() 接受 `auto` 标记,只有自动路径(首屏、后台 tick)会在 USAGE_AUTO_REFRESH_COALESCE_MS(5 秒)内复用刚刚settled 的结果,面板与卡片都改走 这条路径。窗口刻意很小:它只用来折叠一次重复,不是用来展示旧数据;过期之后照旧 重新读取,fetchedAt 因此仍然如实反映这些数字被读取的时刻。 用户按下刷新、保存配置、更换/清除凭据等显式路径一律不传 auto,永远去读 —— 用户 要的就是新数字。 新增回归钉住「自动合并、显式重读、窗口过后重读」三点,以及面板首屏与 tick 都确实 带上 auto 标记;去掉合并逻辑时该用例失败。
后台标签页的 React 树仍然挂载着,而自动刷新循环并不知道这件事:它继续每两分钟 为一个没人看的页面付四次上游调用(whoami + credits + subscriptions + summary)。 现在 tick 会先问可见性:文档隐藏时跳过读取,但照旧重排定时器(循环不能断,否则 恢复可见后就再也不会跑)。重新可见正是唯一「已知屏幕上数据已过期」的时刻,因此 额外立即补读一次。首屏那次仍然无条件执行 —— 那一刻行刚要出现,没有数据就没得 显示。 可见性做成可注入的接缝(AutoRefreshVisibility),默认读 document.visibilityState 并订阅 visibilitychange;非浏览器环境视为始终可见而不是永不刷新。停止最后一个 挂载时解绑监听,避免重挂载累积。 新增回归钉住:首屏必读、隐藏时 tick 不读但继续重排、恢复可见立刻补读、停止后 解绑监听;去掉可见性判断时该用例失败。
CHANGELOG 增补 Unreleased 条目,逐项说明六处改动的动机与保证;adapter-protocol 更正工具 schema 归一化的「每条路径都返回副本」说法并记录记忆化;model-catalog 记录目录首屏读内存与账户端点共享;client-and-settings 记录自动/显式刷新区分与 隐藏页面不轮询;accounts-and-retry 补记超时指纹取自实发字符串。 同时如实写明验证边界:web-search 的 16 条用例在 main 上同样失败,与本批无关; 本批未在真实宿主中人工验证。
上一提交声称「首屏 effect 与面板 tick 都走 auto」,但账户卡片的首屏读取实际调用的是
injected 的 refreshUsage,而它就是显式路径 —— 于是最先发现的那处重复(面板与卡片在
同一秒各读一次同一份报告)根本没有被合并,提交说明与实现不符。
现在把两条路径拆开:injected 新增 refreshUsageAuto()(内部 refresh({ auto: true })),
AccountsCard 的首屏 effect 改调它,刷新按钮继续走 refreshUsage。两者在类型上分开,
顺带让「这个能合并、那个不能」在调用点就看得出来。
新增用例断言 injected 面同时提供两条路径;类型检查与构建通过,全量 1120 条测试除
web-search 那 16 条(main 上同样失败)外全部通过。
补上 refreshUsageAuto()/refreshUsage() 的区分,与上一处修正保持一致。
# Conflicts: # CHANGELOG.md
1. 图片预算门抢在凭据之前抛错(R1/R2,src/adapter.ts) 预算门改为纯会计(projectSurfaceBudget),抛错推迟到凭据与真实通道 落定之后:无凭据时先报 MISSING_CREDENTIAL,不再让宿主对一次发不出去 的请求先做不可逆的持久化图片卸载;预算门与缓存门一次算全 (max(预算, 缓存)),不再退化成两步卸载、也不会把 cache 溢出报成 budget。 withSurfaceOffload() 保留为 413 降档/协议回退的一次性形式。 2. loaded() 只认联网来源(R3/R4,src/gateway-facts.ts) CatalogState 增加 origin(none/disk/network):snapshot() 灌入的磁盘行 与刷新失败后的空目录都不再被当成选择器首屏;冷启动先聊天再开选择器 不会画出无年龄校验的旧目录,刷新失败后也不会先闪一次空列表。 3. 可见性监听按任意卸载顺序解绑(R6,src/client/panel.ts) unsubscribe 句柄从函数局部提升到模块层,启动循环的 surface 先卸载时 不再泄漏 visibilitychange 监听。 4. 账户卡片首屏接线补真实渲染测试(R7) 新增 tests/accounts-card-render.test.ts(react-test-renderer + 既有 CSS/primitives loader):首屏必须调 onRefreshAuto(),刷新按钮必须调 onRefresh();改回旧接线时用例失败。此前只有两个入口存在性断言。 其余测试:gateway-facts 新增 4 条 loaded() 来源判据,adapter 新增 2 条 预算门顺序用例,panel 新增 1 条任意卸载顺序解绑用例。新增 react-test-renderer / @types/react-test-renderer 开发依赖。 验证:typecheck 退出码 0;npm test 1125/1125 通过;npm run build 完成, lib/ 已重建。
- CHANGELOG:修正「只可能抛错,不会改变档位」「loaded 只读内存」等 过度声明;记录 R1/R2/R3/R4/R6 的修复与真实渲染测试;如实写明账户 事实共享只覆盖 whoami/subscriptions、Go 账号会多装配一次正文。 - adapter-protocol:图片预算段改为「会计与抛错分离」的口径。 - model-catalog:loaded() 以联网来源为准,磁盘行与空目录不算热数据。 - client-and-settings:显式刷新在途时仍按 inFlight 折叠;可见性监听 句柄在模块层、任意卸载顺序都解绑;补首屏接线的渲染用例指引。
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
概述
本 PR 是 #98 的审查修复版本:基于
origin/main(b965f9f,v0.15.4)合并 #98 的全部提交(10 个性能提交 + 其自己的 merge),合并时仅CHANGELOG.md一段文本冲突,v0.15.3 / v0.15.4 的内容全部保留;随后叠加两个提交修复审查中发现的问题。#98 的方向与大部分优化经差分验证是真实有效的(14 个线上字节场景逐字节相同、全量测试全绿),但以下四处不能原样合并。
修复清单
图片预算门抢在凭据之前抛错(
src/adapter.ts)IMAGE_OFFLOAD_REQUIRED。宿主(dsh-compaction-image-offload)收到该错误会把最老的 30 张图片持久化卸载(不可逆),重试后才暴露MISSING_CREDENTIAL——一次根本发不出去的请求,永久丢掉图片上下文。projectSurfaceBudget()(不抛错),凭据解析失败先报MISSING_CREDENTIAL。预算门与缓存门分开抛,卸载退化成两步(同上)
offloadSeenImagesForCache+ 双超限时只报预算门(30),重试后才报缓存门(61);cache 溢出被报成 budget,与提交说明「只可能抛错,不会改变档位」不符。max(预算, 缓存),一次抛出。loaded()把磁盘行当联网热数据(src/gateway-facts.ts)snapshot()会把磁盘缓存灌进同一份state.rows,而loaded()只看rows/expiresAt。冷启动"先聊天再开选择器"会拿无年龄校验的旧目录当首屏——正是该判据声称要避免的情况。CatalogState增加origin(none/disk/network),loaded()只认network。刷新失败后的空目录冒充热数据(同上)
rows=[]、expiresAt进入重试窗口,loaded()返回空数组,选择器先闪一次空列表,网络恢复也要等下次打开。rows.length === 0时返回undefined,回落refresh()。可见性监听按卸载顺序泄漏(
src/client/panel.ts)unsubscribe是函数局部变量,只有启动循环的 surface 持有;它先卸载时永远走不到解绑分支,visibilitychange监听随挂载周期累积。首屏 auto 接线补真实渲染测试(
tests/accounts-card-render.test.ts)17b2b5d修过"提交说明说走 auto、实现却停在显式",但只有两个入口存在性断言;把组件改回旧接线时全量测试仍全绿。react-test-renderer渲染真实AccountsCard:首屏必须调onRefreshAuto(),刷新按钮必须调onRefresh();改回旧接线时用例失败。未修项(已如实记录)
provisionalProtocol不读protocolCache)。线上字节不变,属纯 CPU 开销;修它会让多数auto账号失去并发收益,故只在 CHANGELOG/文档记录。验证
npm run typecheck退出码 0npm test1125 / 1125 通过(性能:加速请求路径并合并控制面重复读取 #98 为 1120)npm run build完成,lib/已重建并提交origin/main的 v0.15.3 / v0.15.4 内容未回退(CLI 1.88.0、Kimi K3.1 Pro、引擎兼容区间)react-test-renderer、@types/react-test-renderer(仅测试,不进发布包)与原 PR 的关系
4a0921a代码与测试、31d95de文档)。回滚
两个提交彼此独立,可单独 revert;合并提交不改代码,只把 #98 的提交带入最新 main。