DeepSeek Harness 删掉最后一个市场源,列表却又回来:在途刷新回写的竞态与修法
删掉最后一个市场源,列表立刻清空,界面显示「添加第一个源」——几秒后,那个源的插件列表又整整齐齐地回来了。 来源配置其实没变,indexes 仍是空数组、selected 仍是 null,只有 listing 被覆盖了。把它按时间线拆成三段,就能看清这是一次「在途请求覆盖用户操作」的经典竞态(#11103)。
DeepSeek Harness 发生前:一次刷新带着它的「代号」出发
每次刷新开始时,客户端会记下当前的读取代号;请求返回时对比代号,不一致就应丢弃。
- 配置一个索引源 A,正常加载插件市场。
- 触发一次刷新,让
pluginMarket.search()的响应保持挂起。 - 此时这次读取已经持有它出发时的代号。
- 正常情况下,只要代号被后续操作递增,这次返回就会被判为过期。
- 关键就在这里:它是否过期,取决于有没有人递增那个计数器。
DSH插件 发生时:清空列表,却没有作废在途读取
loadIndexes() 在选中项为空时把 listing 清空,但这条分支不递增代号,于是那份在途读取仍然「有效」。
- 删除源 A,
setIndexes([])与随后的listIndexes()完成。 - 此刻状态确实是
indexes: []、selected: null、listing: null。 - 但清空动作只改了 listing,没有让在途读取失效。
- 对比另一条路径:当
selected !== 当前 listing.url时会新起一次load(),那次会递增代号,旧的读取自然作废。 - 缺口只有一条分支宽:只有
selected === null这条清空路径不做失效。
DeepSeek Harness 发生后:返回结果无条件写回
settle() 用它出发时记下的 url 直接写回状态,不检查这个来源是否还在配置里。
- 在途请求返回后走到
settle(),后者调用patch({ listing, indexes })。 patch是合并式更新,所以indexes: []与selected: null被保留。- 只有
listing被换成旧来源的数据,url又变回 A。 - 成功时状态是
status: 'ready';失败时同样会把旧列表写回,只是status: 'error'。 - 受控环境里成功与失败各复现 10/10,说明这不是偶发。
DeepSeek插件 为什么用户在界面上能踩到
源管理菜单在列表加载期间仍然可用,删除按钮只在「保存来源」进行时被禁用,所以刷新→管理源→删除这条顺序是可达的。
- 界面按
listing === null选择空状态,否则渲染浏览视图。 - 恢复后的列表不会被隐藏,因为
selected与indexes都是空的。 - 于是用户看到的就是「我明明删了,它又回来了」。
- 需要注意边界:这份报告没有声称源被加回配置,也没有声称插件被自动安装。
- 它只描述显示层被旧数据覆盖。
DeepSeek Harness 恢复到真正的空状态
重新读一次源列表,或构造新控制器;再点一次 ensure() 没用。
- 调用
reload()重新读取源列表,列表回到空。 - 或者销毁并重建控制器,效果相同。
- 再点一次
ensure()不会清除,它沿用被恢复的那份列表。 - 再刷新一次会报
plugin-market/unknown-index,但旧列表依旧保留。 - 这些是 API 层验证,未覆盖点击陈旧插件的安装按钮这类交互。
DSH插件 修法:在清空的同一个同步块里递增代号
社区给出的最小修法是在 loadIndexes() 的清空分支里先递增 listingRead 再清空 listing;位置必须在同一个同步块内。
- 在
selected === null分支里执行this.listingRead++,让在途读取立刻过期。 - 紧接着
this.patch({ listing: null })。 - 顺序不能反,也不能放在任何
await之后——晚一步就等于没做。 - 可选的双保险:在
settle()里校验 url 是否仍在配置中。 - 这种递增写法与另一处
indexRead守卫loadIndexes的做法一致,风格统一。 - 配套测试可以沿现有 helpers 写:先让刷新挂起,删除源并断言为空,再
resolve那个挂起的结果,最后用setTimeout(resolve, 0)等微任务队列结算再断言仍为空。
一分钟自查:这次回来属于哪一种
按「删除与回来之间的时间差」分三类:零延迟是配置写回,有等待是在途回写,跨进程是持久化。
| 现象 | 时间差 | 判断 |
|---|---|---|
| 删除后立刻回来 | 0 秒 | 设置被写回磁盘 |
| 删除后几秒回来 | 有等待 | 在途刷新回写 |
| 重启后仍在 | 跨进程 | 配置或缓存持久化 |
- 先记下删除时刻,再看列表回来的时刻。
- 有等待的,去查这段时间里是否有刷新在进行。
- 重启后仍在的,去查设置文件而不是内存状态。
注意事项
- 别把「列表回来」当成「源被加回来」。配置层一直是空的。
- 别忽略失败分支。失败同样会写回旧列表,只是状态标成 error。
- 别用重复刷新当恢复手段。它只会报
unknown-index并保留旧列表。 - 修复要注意时序位置。放在 await 之后等于没修。
- 记录版本与提交。社区在 rc.3 与 master 上都复现过,路径一致。
如果你的列表在删除后立刻回来、中间没有等待,那不是这条竞态,去查设置是否被写回磁盘。相关排查见 dsh 插件市场打不开、插件列表加载不出来?DSH Plugin Hub 网络、代理与证书排查。
常见问题
因为 DeepSeek Harness 清空列表时没有作废在途的刷新。loadIndexes() 在 selected === null 时把 listing 清空,但没递增读取代号;刷新返回后仍然会 settle() 写回旧来源的列表。
没有,DeepSeek Harness 的配置里源并没有被加回来。写回用的是 patch 合并式更新,indexes: [] 与 selected: null 都保留了下来,只有 listing 回来了。所以这是显示层被覆盖,不是配置被改。
会,即使刷新失败,DeepSeek Harness 也会把旧列表写回。社区在受控环境里测了 10/10 成功和 10/10 失败,两种结果都会把旧列表写回,只是 status 分别是 ready 与 error。
在 DeepSeek Harness 里重新读一次源列表(reload()),或构造一个新的控制器,才能让列表回到真正的空状态。再点一次 ensure() 不行,它沿用被恢复的那份列表;再刷新一次会报 plugin-market/unknown-index,但旧列表仍在。
修法是在 DeepSeek Harness 的 loadIndexes() 清空列表的同一个同步块里递增 listingRead,让在途读取失效;也可以在 settle() 里加一道来源校验作为双保险。关键是不能放在任何 await 之后。
相关术语
- listingRead
- listingRead 是插件市场客户端里用于标记列表读取代号的计数器。刷新开始时记下当前值,回来时对比,不一致就说明这次读取已过期。— https://github.com/deepseek-ai/deepseek-harness/discussions/11103
- settle()
- settle() 是把一次刷新的结果写回状态的函数,当前实现直接用发起时的 url 写回,不检查该来源是否仍被配置。— https://github.com/deepseek-ai/deepseek-harness/discussions/11103
- loadIndexes()
- loadIndexes() 是读取已配置市场源列表的函数。它在选中项为空时清空 listing,但在这条分支上不作废正在进行中的列表读取。— https://github.com/deepseek-ai/deepseek-harness/discussions/11103
- in-flight(在途)
- in-flight(在途)是已经发出但尚未返回的异步请求。用户在此期间修改设置时,返回结果可能已经不再对应当前界面状态。— https://github.com/deepseek-ai/deepseek-harness/discussions/11103
来源
- #11103 — Discover restores a removed market when an in-flight refresh finishes· deepseek-ai(GitHub Discussions)
- deepseek-ai/deepseek-harness(源码仓库)· GitHub