diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index d3005c3..beece58 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -45,7 +45,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端, ## 当前阶段 -当前项目已完成 Phase 0~2、T-301 与审核整改 `T-604`、`T-605`。Windows 安全路径阻断项已关闭;下一步按 Phase 1 安全交叉审核顺序落成中央目录预扫描整改,随后处理断电耐久和签名向量,之后恢复 T-302。 +当前项目已完成 Phase 0~2、T-301 与审核整改 `T-604`、`T-605`。Windows 安全路径阻断项已关闭;Phase 2 交叉审核已定稿并落成首个整改 `T-606`,下一步领取并实现图标缓存并发/有界读取/内存上限,其余审核整改与 Phase 1 中央目录预扫描继续串行处理,T-302 暂后置。 优先路径: @@ -53,7 +53,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端, 2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。 3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。 4. 已完成 T-604:modern/Win7 workspace 与 Gio 版本解析彻底隔离。 -5. 下一步落成 ZIP 中央目录预扫描整改任务,继续串行完成 Phase 1 安全阻断整改,再恢复 T-302/T-303 和 Phase 4-6。 +5. 下一步实现 T-606,关闭正式图标接入前的并发与资源边界;随后按已定稿审核顺序串行落成后续整改,再恢复 T-302/T-303 和 Phase 4-6。 ## 领取任务规则 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index dc3e5a4..b9aa22d 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -57,6 +57,14 @@ Phase 1 安全整改按 `docs/review/phase1-security-review.md` 的交叉复核 | T-203 | 主界面软件列表 | T-202, T-003 | 虚拟列表 + 搜索/分类/视图切换;控件状态按软件 ID;数百项滚动流畅;Layout 无 IO | | T-204 | 软件详情与图标缓存 | T-203 | 详情弹层;图标内存+磁盘缓存(按 DPI);断网可显示缓存图标 | +#### Phase 2 交叉审核加固 + +Phase 2 整改按 `docs/review/phase2-review.md` 的交叉复核定稿顺序串行落成。T-606 先关闭正式图标接入前的并发、读取和内存边界;UI 线程投递、适配器契约、VisibleItems 快照与 shell 拆分在前一整改完成并提交后再正式编号。 + +| ID | 任务 | 依赖 | 验收要点 | +| --- | --- | --- | --- | +| T-606 | 收紧图标缓存并发与内存边界 | T-204, T-605 | 按 key in-flight 去重且不同 key 并行;Fetcher 流式有界读取;memory LRU 同时限制字节/条目;modern/win7 删除 app 时剪枝内存 ImageOp | + ### Phase 3 · 下载与安装 Phase 3 的 T-301 → T-302 → T-303 是安全关键依赖链,任务之间保持串行。T-302 起继续由单 Agent 完成规格、实现、测试、安全检查和提交,不启动子 Agent;完整规则见 [`tasks/README.md`](tasks/README.md) 与仓库 `AGENTS.md`。 diff --git a/docs/current-state.md b/docs/current-state.md index 7a20c87..073a1ee 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -12,8 +12,8 @@ ## 当前快照 -- 日期:2026-07-16 -- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604、T-605 已完成,T-302 继续暂后置 +- 日期:2026-07-17 +- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604、T-605 已完成;Phase 2 首个审核整改 T-606 已落成待领取,T-302 继续暂后置 - 技术栈:根 Go 1.25 workspace 只纳入 core/app-modern,`app-win7/go.work` 独立纳入 core/app-win7;版本闸门证明 modern Gio v0.10.1 与 win7 Gio v0.6.0 不交叉解析 - 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略、安全 ZIP 解压/回滚原型、无 IO 软件列表模型、可信图标缓存和默认并发 2 的持久可恢复下载队列;modern/win7 AppShell 已实现搜索/分类/视图、惰性列表、详情右栏与内存图标 - 测试:core 覆盖 Catalog、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性、列表/图标、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 500 项虚拟列表、ID 控件稳定性、详情/ApplyIcon 与平台 stub;安装恢复矩阵保持通过 @@ -21,14 +21,14 @@ - 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令) - 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1` - 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交 -- 当前 blocker:无;下一步按 Phase 1 安全审核定稿落成 ZIP 中央目录预扫描整改任务,T-302 继续后置 +- 当前 blocker:无;下一步领取 T-606,收口图标缓存按 key 并发、流式读取上限、内存 LRU 与双 shell 图标剪枝;其余 Phase 2/Phase 1 审核整改继续串行,T-302 继续后置 ## 当前目录要点 | 路径 | 状态 | 说明 | | --- | --- | --- | | `docs/` | 已有 | harness coding 文档集(本次初始化完成) | -| `docs/tasks/` | 已有 | Phase 0~2、T-301、T-604 与 T-605 已完成;下一 Phase 1 安全整改尚未落成;T-302 暂后置 | +| `docs/tasks/` | 已有 | Phase 0~2、T-301、T-604 与 T-605 已完成;T-606 已落成待领取;其余审核整改尚未编号,T-302 暂后置 | | `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 | | `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、图标缓存、可恢复下载队列与 Phase 1 安装安全原型 | | `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情和内存图标 | @@ -42,7 +42,7 @@ - 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`;审核整改 `T-604`、`T-605`。 - 正在进行:无。 -- 下一个可领取任务:无;先按 `docs/review/phase1-security-review.md` 落成 ZIP 中央目录预扫描整改任务。 +- 下一个可领取任务:`T-606`(收紧图标缓存并发与内存边界),依赖 `T-204`、`T-605` 均已完成。 ## 当前可运行内容 diff --git a/docs/review/phase2-review.md b/docs/review/phase2-review.md new file mode 100644 index 0000000..c0a79a3 --- /dev/null +++ b/docs/review/phase2-review.md @@ -0,0 +1,287 @@ +# Phase 2 审查(T-201 ~ T-204:清单接入 / 本地状态 / 虚拟列表 / 详情与图标缓存) + +> 审查范围:`2e21c9f`(T-201 catalog 接入)、`819b1cd`(T-202 本地状态识别)、`db8d93e`(T-203 虚拟列表)、`a52acbf`(T-204 详情与图标缓存)。 +> 审查视角:全栈开发工程师。 +> 审查方式:静态代码审计 + 对照 Phase 0/1 遗留项核验。因 WSL 无 Go,未执行测试。 +> 附带确认:Phase 0 的 P1(workspace 隔离,T-604)与 Phase 1 的 P0(Windows 路径,T-605)是否真正闭合。 +> 日期:2026-07-17 + +## 总体判断 + +Phase 2 质量稳定,延续了 Phase 1 的工程水准。**Phase 0 与 Phase 1 审查提出的两个结构性问题已被真正修复并接线**(见"遗留项闭合确认")。Phase 2 本身无安全级缺陷,主要是若干性能与可维护性优化项。M3 前半(清单→列表→详情)成立。 + +## 遗留项闭合确认(重要) + +| 遗留项 | 状态 | 证据 | +| --- | --- | --- | +| Phase 1 P0 · Windows dot-space 路径逃逸 | **已闭合** | 新建 `core/internal/safepath`:`validateSegment` 逐段拒绝首/尾 ASCII 空格、尾随句点、DOS 设备名(含 `COM¹²³` 变体)、控制字符与 `<>:"\|?*`;`JoinUnder` 在 `filepath.Join` 后用 `filepath.Rel` 做 destination 包含性兜底。`extractor.go` 已改用 `safepath.ValidateRelative` + `JoinUnder`(不再只靠 POSIX `path.Clean`);`parser.go`、`storage/installed_app.go` 共用同一校验器,无各层漂移。两条整改建议(逐段校验 + 包含性兜底)都落地。 | +| Phase 0 P1 · 根 workspace Gio 版本污染 | **已闭合**(T-604) | 见 `7efcab5 Isolate modern and Win7 workspaces`(需在该任务范围单独确认,非本次重点) | + +## 逐模块核验 + +### T-201 · 清单接入(catalog/parser + filter + client) + +- `parser.go` 严格:`DisallowUnknownFields`、拒绝尾随 JSON、拒绝重复 app id、signature 必须 strict Base64 且解码为 64 字节、URL 校验。签名验证已在 T-101 闭环,parser 作为 `DocumentValidator` 接入 loader(Phase 1 已确认 `TestClientValidatesBeforeReplacingCache`)。 +- `filter.go`:**channel 交叉拒绝正确**——`manifest.Channel != target.Channel` 直接整份拒绝,Win7 客户端不会消费 modern 清单;`hidden` 跳过,`deprecated`/`min_os` 不满足/无对应架构包的项**保留可见并带 Reason、不可安装**,符合"不兼容软件可见说明但不可下载"。 +- `supportsOS` 用 rank map(7/10/11),高版本系统可装低 min_os 应用,正确。 +- `entry.Package = &publishedPackage`:`publishedPackage` 是循环体内 `:=` 新变量,逐次地址独立,无 Go 循环变量取址陷阱。 + +### T-202 · 本地状态识别(domain/semver + status_resolver + storage/installed_app) + +- `semver.go` 符合 SemVer 2.0.0:prerelease 数值/字母混合比较、数值 < 字母、较短 prerelease 优先、build 元数据不参与排序、prerelease 数值前导零拒绝。`compareNumericText` 用"长度优先再字典序"比较数值文本,规避大版本号整数溢出,且因核心标识拒绝前导零而正确。 +- `status_resolver.go`(Phase 0 复核已见):按"恢复事务→活跃操作→运行→不兼容→是否安装→是否有更新"从 facts 推导单一可见状态,IO-free。真实的状态权威在此,`allowedTransitions` 迁移表仍是并行的测试脚手架(Phase 0 复核已记,待收口)。 +- `installed_app.go` 共用 safepath 校验文件路径。 + +### T-203 · 虚拟列表(application/CatalogListModel + 两个 Gio 适配) + +- **共享 ViewModel 纪律落地**(回应 Phase 0 P2):`core/application.CatalogListModel` 持有 items/visible/categories/query/category/view/selectedID,全部 IO-free 纯内存;搜索、单分类、all/installed/updates 视图、选中态都在 core。 +- **控件状态按 app ID 保存**(架构硬约束满足):`shell.rows map[string]*rowControls`、`shell.icons map[string]paint.ImageOp`、`categoryControls map[string]*widget.Clickable` 均以稳定 ID/分类名为键;`SetItems` 按 `item.ID` 保留既有 `rowControls`,列表过滤/重排时点击态不串行。 +- Gio 侧用惰性 `layout.List`;Layout 内 grep 无 `os.`/`http.`/`sha256`/`ReadFile`,确认无 IO。 + +### T-204 · 详情与图标缓存(catalog/icon_cache + Gio 详情) + +- `icon_cache.go` 安全性对齐下载/清单缓存: + - `cacheKey` 要求 `sha256:` 前缀 + 校验 hex 解码/长度/DPI 范围(48–768);文件名 `<64hex>-.icon` 由已验证 hex + int 拼成,无路径分隔符,无穿越面。 + - 内存→磁盘→远端;磁盘与远端字节**都重新 SHA-256 复验 + 尺寸/大小复核**,不盲信磁盘。 + - **图片解压炸弹防御**:`DecodeConfig` 先取宽高并按 2048 上限拒绝,**再**做完整 `image.Decode`,阻止"小文件声明巨大尺寸";输入 2 MiB 上限。 + - 磁盘 Lstat 拒绝符号链接/非普通文件;原子写(temp+chmod 0600+sync+rename);损坏普通缓存删除重取。 +- Gio 详情右栏只读 `SelectedItem()` 与内存 `paint.ImageOp`,关闭详情不清筛选/列表位置。 + +## 需优化项(按优先级) + +### P1 · 图标缓存的 mutex 横跨网络 fetch,拖累滚动吞吐 + +**现状:** `IconCache.Load` 全程 `cache.mu.Lock()` + `defer Unlock`,且在锁内调用 `cache.fetcher.FetchIcon(ctx, request)`(网络 IO)。 + +**影响:** 任意一个图标的远端拉取在飞行中时,**所有**其他 `Load`(哪怕是内存命中)都阻塞在同一把锁上。数百项列表滚动时按需拉图标会形成 convoy,与"数百项流畅滚动"验收目标直接冲突。当前实现用全局串行换取了"同一图标不重复拉取",但牺牲了不同图标的并行度——对网络受限的图标加载,并行度通常更重要。 + +**建议:** 锁内只做内存/磁盘查与内存写,fetch 在锁外(check→release→fetch→re-acquire→store);或用 per-digest 锁 / `singleflight`(注意保持 core 的 Go 1.20 兼容)。 + +### P2 · 两个 Gio shell 的并行布局代码持续翻倍 + +**现状:** T-203 给 `app-modern/ui/gio/shell.go` 加了 712 行、`app-win7` 加了 626 行;modern shell 已 922 行。共享 ViewModel 覆盖的是**逻辑**,布局/控件代码仍是两份平行实现。 + +**影响:** modern(Gio v0.10.1)与 win7(Gio v0.6.0)API 有差异,平行是既定取舍;但每新增一个视图(详情、下载、设置、授权),这 600+ 行平行代码就再翻一倍,且两份必须手动保持行为一致,漂移风险随视图增加上升。 + +**建议:** 不强行共享 Gio 控件代码,但补一层**跨适配的行为一致性测试**(对同一 `CatalogListModel` 状态断言两个 shell 的可见项/选中项/视图切换语义一致),成本低、能挡住漂移;长期可评估数据驱动的布局描述层(纯数据、非 Gio)减少平行量。 + +### P3 · 多标签筛选:需求与实现口径不一致 + +**现状:** `CatalogListModel` 提供单分类过滤 + 标签并入搜索(`matchesQuery` 含 Tags),但没有"多标签筛选"。`docs/routes.md` 写"分类和多标签筛选",而 `02-requirements.md` 表格把标签定位为搜索项。 + +**影响:** 文档内部口径不一,实现满足 requirements 表但不满足 routes 的"多标签筛选"。 + +**建议:** 先裁定多标签筛选是否属 MVP:若是,补 `CatalogListModel` 的多标签过滤;若否,改 `routes.md` 表述与实现对齐。不要留文档-实现二义。 + +### P4 · `VisibleItems()` 返回可被 refilter 复用的切片 + +**现状:** `refilter` 用 `model.visible = model.visible[:0]` 复用底层数组,`VisibleItems()` 直接返回 `model.visible`,注释称"immutable-by-convention"。 + +**影响:** 单线程 UI 下每帧现取现用,安全;但任何调用方若跨一次 `refilter` 持有该切片,底层数组会被 append 覆盖,是隐性 footgun。 + +**建议:** 返回拷贝,或把"不得跨 SetQuery/SetView/SetItems 持有"写成硬约束注释并在测试固化。 + +### P5 · shell.go 单文件 922 行,随视图增长将更臃肿 + +**现状:** 搜索、分类条、虚拟列表、详情右栏都在一个 `shell.go` 的 Layout 链里。 + +**建议:** 视图增多前,按视图拆文件(list/detail/category),降低单文件复杂度与两适配漂移面。优先级低。 + +### P6 · 图标磁盘缓存遇 symlink 硬失败而非自愈 + +**现状:** `loadDisk` 遇符号链接返回 `ErrIconCacheUnsafe`,`Load` 对该错误早返回、不删除、不回退远端。 + +**影响:** 被植入的 symlink 缓存项会**永久**卡住该图标直到人工清理。相较普通损坏缓存"删除重取"的自愈,这里选择了保守硬失败。 + +**建议:** 保守是可辩护的(不自动删可疑文件),但至少记一条明确告警/诊断,便于运维定位;或对 symlink 缓存项也走"隔离到 .quarantine 后回退远端"。 + +## 结论与处理顺序 + +Phase 2 可作为后续下载/安装 UI 接入的可信基线。建议顺序: + +1. **[性能 · 影响验收]** P1:图标缓存 fetch 移出锁,消除滚动 convoy。 +2. **[防漂移 · 低成本]** P2:补跨适配行为一致性测试。 +3. **[消除二义]** P3:裁定多标签筛选归属并对齐文档/实现。 +4. **[健壮性]** P4/P6:VisibleItems 拷贝或硬约束;图标 symlink 缓存告警/隔离。 +5. **[可维护性 · 低优先]** P5:shell.go 按视图拆分。 + +> 声明:本报告为静态审计;P1 的吞吐影响、两适配行为一致性建议在真实 Windows Gio 运行与 `go test` 下验证。Phase 0 P1(T-604)与 Phase 1 P0(T-605)已确认接线闭合,但其各自任务范围的完整验收以对应任务复核为准。 + +## Codex 全栈二次复核 + +> 复核视角:Codex 全栈开发工程师。 +> 复核方式:对照 T-201~T-204 任务边界、当前实现调用点、现行需求/路由文档和双 workspace 测试逐项核验。 +> 复核基线:`6fd19d0`。 +> 日期:2026-07-17 + +### 复核结论 + +原审核的总体判断成立:Phase 2 没有阻断后续开发的功能或安全缺陷,T-201~T-204 可以作为后续下载/安装 UI 接入的基线;T-604/T-605 的代码级整改也已经接线闭合。 + +但原整改清单不宜原样落任务: + +1. P1 的并发问题真实,但当前 `IconCache` 尚未接入生产装配,不能直接认定已经破坏列表滚动验收。 +2. P2/P4/P5 的现象成立,实施方式需要兼顾双 Gio 隔离与每帧分配成本。 +3. P3 引用了已经不存在的文档冲突,应撤销。 +4. P6 是可辩护的 fail-closed 安全策略,不应定性为 Phase 2 缺陷。 +5. 原审核遗漏了远端响应读取上限和 Gio UI 线程投递两个正式接入前必须冻结的约束。 + +### 对原优化项的修订 + +#### P1 · 接受问题,修正影响范围与落地方案 + +`IconCache.Load` 当前从 `cache.mu.Lock()` 到返回全程持锁,锁内包含磁盘读取、图片验证、`FetchIcon` 网络调用、磁盘写入和内存更新。不同 digest/DPI 的请求因此也会互相阻塞,慢请求还会阻塞本应很快的内存命中。该并发设计需要修复。 + +但当前生产代码没有 `IconCache.Load` 调用者,仅测试直接调用;T-204 也明确把真实网络请求装配留给后续任务。图标未命中时 UI 使用内存占位,Layout 不等待 `Load`。因此准确结论是: + +- 这是正式图标后台加载接入前必须关闭的吞吐风险。 +- 它尚不是已经复现的“滚动卡顿”或 Phase 2 验收失败。 + +建议使用按 cache key 的 in-flight 去重:全局 mutex 只保护 memory/in-flight map,不同 key 的磁盘和网络工作可以并发;同一 key 只允许一个 leader 获取并让等待者复用结果。不要只做简单的 check→unlock→fetch→lock→store,否则同一图标仍会重复下载和竞争写盘。 + +并发测试至少覆盖: + +- 慢 key 不阻塞另一 key 的内存命中。 +- 不同 key 可以并行获取。 +- 同一 key 并发只获取一次。 +- leader 失败、context 取消和磁盘写 warning 能正确传播,且不会遗留永久 in-flight 状态。 + +#### P2 · 部分接受,测试应覆盖适配器接线而非重复 ViewModel 语义 + +两个 `shell.go` 的平行实现和持续膨胀属实,modern/win7 的行为漂移风险存在。但筛选、可见项、选中项和视图切换的核心语义已经由共享 `core/application.CatalogListModel` 决定并在 core 测试覆盖;把相同 ViewModel 断言再对两个 shell 各跑一次,不能有效发现 Gio 事件接线漂移。 + +更有价值的做法是建立一组适配器交互契约,分别在 modern 与 win7 包中复用同一用例矩阵,验证: + +- 搜索、分类、视图按钮和清空筛选事件实际更新共享 model。 +- 行点击打开正确 app ID,关闭详情保留筛选和列表上下文。 +- 500 项有限 viewport 仍只布局可见子集。 +- row controls 按 app ID 保持,删除的 app/category controls 被释放。 +- 空 Catalog 与过滤无结果保持不同恢复语义。 + +测试辅助数据可以共享,但不要让 modern workspace import win7 Gio,也不要为了测试重新合并两个不兼容的 Gio 版本。 + +#### P3 · 撤销:现行文档不存在多标签筛选冲突 + +现行文档口径一致: + +- `docs/02-requirements.md` 定义“按名称/标签搜索,按分类和全部/已安装/可更新视图筛选”。 +- `docs/routes.md` 明确记录“多标签复选随对应用例后续接入”。 +- `docs/tasks/T-203.md` 的边界明确“不实现多标签复选”。 + +当前实现满足 Phase 2 已冻结范围。除非产品后来把多标签复选提升为 MVP,否则无需修改代码或文档,P3 不应进入整改任务。 + +#### P4 · 接受 API footgun,但不建议每帧无条件返回拷贝 + +`VisibleItems()` 暴露 `model.visible`,而 `refilter()` 用 `model.visible[:0]` 复用 backing array。调用者若跨一次 refilter 保存旧切片,内容可能被覆盖;调用者也能直接改写当前切片元素。这是导出 API 的生命周期风险。 + +不过两个 Gio Layout 每帧都会读取 `VisibleItems()`,若每次访问都复制数百项,会引入与性能目标相反的持续分配。建议二选一: + +1. `refilter` 每次构造新的 snapshot backing array并原子替换,访问方约定只读;旧 snapshot 可安全保留到下一帧之后。 +2. 提供 `VisibleLen()` + `VisibleItem(index)` 只读索引接口,Layout 不再持有内部切片。 + +无论采用哪种方式,都应补“跨 refilter 持有旧快照不会变化”或“调用者无法取得内部切片”的测试。 + +#### P5 · 接受为低优先级维护项 + +按 list/detail/category 或 header/content/detail 拆分同 package 文件有助于降低单文件复杂度和双适配 diff 噪声,且不会破坏 Gio 版本隔离。建议在新增下载队列、授权或设置视图前完成,但不需要为了纯文件拆分阻断当前功能任务。 + +#### P6 · 改为安全策略与诊断观察项 + +对 symlink/非普通缓存项返回 `ErrIconCacheUnsafe` 并停止回退,是明确的 fail-closed 策略:代码不会跟随可疑链接读取或写入攻击者选择的位置。自动删除、rename 或隔离可疑对象需要额外处理 Windows reparse point、跨进程竞争和 TOCTOU,不能默认比硬失败更安全。 + +正式装配日志/错误 UI 时应把 `ErrIconCacheUnsafe` 转成可定位的诊断和人工清理指引。只有产品明确接受自动隔离策略并补齐攻击测试后,才考虑 quarantine;P6 不作为 Phase 2 必修复代码缺陷。 + +### 原审核遗漏的接入风险 + +#### 新 P1 · 2 MiB 校验不能限制 Fetcher 已经读取和分配的响应 + +`IconFetcher.FetchIcon` 返回完整 `[]byte`,`IconCache.validate` 在 Fetch 完成后才检查 `len(document)`。这可以阻止超大对象进入缓存,却不能阻止未来 HTTP Fetcher 先下载并在内存中分配任意大响应。原审核把它概括为“输入 2 MiB 上限”不够准确。 + +正式网络 Fetcher 必须在读取阶段同时执行: + +- 拒绝明显超过上限的 `Content-Length`。 +- 用 `io.LimitReader(maxBytes+1)` 或等价有界读取处理缺失/伪造的长度头。 +- 读到 `maxBytes+1` 即返回资源超限,不得把完整大响应交给 `IconCache`。 + +该约束应与 P1 并发整改一起进入正式图标加载任务。 + +#### 新 P2 · `ApplyIcon` 必须在 Gio UI 事件循环中执行 + +modern/win7 的 `ApplyIcon` 都直接写 `shell.icons` map,Layout 同时读取该 map,当前没有 mutex。现阶段只有测试在同一 goroutine 调用,没有数据竞争;未来若后台加载 goroutine 直接调用 `ApplyIcon`,会产生 map race,并且不会自动遵守 Gio 的 UI 更新模型。 + +正式接入必须保持现有架构硬约束:后台任务只发布完成事件;UI goroutine 在 drain/ApplyEvent 阶段执行 `DecodeIcon` 之外的 model/ImageOp 更新并调用 `Window.Invalidate`。应增加 `go test -race` 可执行环境下的事件投递测试或等价的单线程契约测试,禁止把 `ApplyIcon` 宣称为可从任意 goroutine 调用的线程安全 API。 + +### 验证补充 + +原审核因 WSL 无 Go 未执行测试。本次在 Windows 环境按隔离 workspace 补跑并通过: + +- Go 1.20.14、`GOWORK=off`:`go -C core test -count=1 ./catalog ./application ./domain ./storage`。 +- Go 1.25.0、根 `go.work`:`go -C app-modern test -count=1 ./ui/gio`。 +- Go 1.20.14、`app-win7/go.work`:`go -C app-win7 test -count=1 ./ui/gio`。 + +测试通过证明当前覆盖范围内实现一致,不能替代未来 IconFetcher 并发/有界读取测试、UI 线程投递测试或真实 Windows 滚动性能测量。 + +### 修订后的处理顺序 + +1. **[正式图标接入前 · 最高]** `IconCache` 改为按 key in-flight 去重,网络/磁盘工作移出全局锁;HTTP Fetcher 在读取阶段执行硬大小上限;补并发、取消和有界读取测试。 +2. **[正式图标接入前 · 契约]** 后台结果通过事件队列回到 Gio UI goroutine 后执行 `ApplyIcon`/Invalidate,禁止后台 goroutine 直接改 shell map。 +3. **[防漂移]** 为 modern/win7 补适配器交互契约测试,重点验证 Gio 事件接线、虚拟化和上下文保持,不重复证明共享 ViewModel 的纯逻辑。 +4. **[API 健壮性]** 修正 `VisibleItems` 快照生命周期,避免每帧无条件复制。 +5. **[可维护性 · 低优先]** 在继续增加视图前按职责拆分两个 `shell.go`。 +6. **[观察项]** 为 `ErrIconCacheUnsafe` 增加上层诊断/人工清理指引;是否自动 quarantine 另行做威胁模型裁定。 + +> 二次复核裁定:保留 Phase 2“可信基线”的总体结论;接受原 P1/P2/P4/P5 的问题事实但修订定级或落地方式;撤销原 P3;把原 P6 降为安全策略观察项;新增远端有界读取和 UI 线程投递两项正式接入门槛。当前不需要回滚或重做 T-201~T-204。 + +## 交叉复核裁定(定稿) + +> 复核视角:Claude 全栈开发工程师,对 Codex 二次复核逐条核验后裁定。 +> 核验方式:重读当前 `routes.md`/`T-203.md`/`02-requirements.md` 措辞;对照 `icon_cache.go` 的 `validate` 位置、`shell.go` 的 `ApplyIcon`/`icons` map 读写点、`SetItems` 剪枝范围、`IconCache.Load` 生产调用者确认技术主张;因 WSL 无 Go,未执行测试。 +> 日期:2026-07-17 + +### 承认原审核错误 + +本轮 Codex 复核比原审核更准。原审核有一处 stale 误判与两处不精确表述,应更正: + +- **原 P3(多标签筛选文档冲突)撤销——原审核错。** 当前文档口径一致:`routes.md` 第 26 行"多标签复选随对应用例后续接入"、`T-203.md` 边界"不实现…多标签复选"、`02-requirements` 把标签定位为搜索项。原 P3 依据的是最初 routes.md 版本的记忆,**未重读当前文档**;Codex 在 T-203 已将其更新为"后续接入"。不存在文档-实现二义,P3 不进整改。 +- **原 T-204 "输入 2 MiB 上限" 不精确。** `validate` 的 `len(document) > maxBytes` 在 `FetchIcon` 返回完整 `[]byte` **之后**执行,只界定"进缓存",不界定 fetch 内存。 +- **原 T-204/P1 "ApplyIcon 从后台调用" 表述有误且危险。** `ApplyIcon` 写 `shell.icons`、Layout 读同 map、无 mutex;当前仅测试同 goroutine 调用,无生产调用者。正确契约是后台发事件、UI goroutine 应用,不得从任意 goroutine 调 `ApplyIcon`。 +- **原 P1 "已直接冲突流畅滚动验收" overstate。** `IconCache.Load` 无生产调用者(仅测试),应定级为"正式图标接入前必须关闭",非"已破坏验收"。 + +### 已确认的事实(可作为后续任务前提) + +| 项 | 结论 | 证据 | +| --- | --- | --- | +| 多标签筛选文档一致 | 属实(P3 撤销) | `routes.md:26` / `T-203.md:50` / `02-requirements.md:28` 三者一致,多标签复选明确后续接入 | +| 图标大小上限只界定 cache 入口 | 属实(NEW P1) | `validate` 在 `FetchIcon` 返回完整 `[]byte` 后才查 `len`;正式 HTTP Fetcher 须 Content-Length 拒绝 + `io.LimitReader(maxBytes+1)` | +| ApplyIcon/icons map 无锁 | 属实(NEW P2) | `shell.go:98` 写、`shell.go:514` 读,无 mutex;仅测试调用,无生产调用者 | +| IconCache 未接生产 | 属实 | `IconCache.Load` 仅测试调用,T-204 把网络装配留后续 | +| 内存缓存无界(双方原漏) | 属实(新增) | `IconCache.memory` 无 delete/LRU/上限;`SetItems` 重建 rows/categories 但**不剪枝** `shell.icons`,移除的 app 图标永久滞留 | + +### 采纳 Codex 的修订与 sharpen + +- **接受** 原 P3 撤销、NEW P1(远端有界读取)、NEW P2(UI 线程投递契约)。 +- **接受** P1 落地方案:按 cache key 的 in-flight 去重(全局锁只护 memory/in-flight map,不同 key 磁盘/网络并发,同 key 单 leader),优于 naive check-unlock-fetch-lock-store(后者同图标会重复拉取)。 +- **接受** P2 sharpen:适配器**交互契约测试**(验证 Gio 事件真更新 model、行点击开对 app ID、viewport 虚拟化、controls 按 app ID 保持),而非把共享 ViewModel 语义再断言一遍。 +- **接受** P4 sharpen:`refilter` 构造新快照原子替换(筛选变化时分配),而非每帧返回拷贝。 +- **接受** P6 收紧:fail-closed + 诊断为默认;自动 quarantine 需先做威胁模型裁定(reparse point / TOCTOU / 跨进程竞争),不默认更安全。 + +### 新增(双方原漏):无界内存缓存 + +- `IconCache.memory` 无淘汰;`shell.icons` 在 `SetItems` 不按当前 items 剪枝。数百款 × 每图标最大 2 MiB,内存只增不减(典型图标数十 MB,极端可更高)。 +- 建议:内存层加容量/LRU 上限;`SetItems` 按当前 items 剪枝 `shell.icons`。与 P1 图标接入整改合并处理。 + +### 最终处理顺序(定稿) + +1. **[正式图标接入前 · 最高]** `IconCache` 按 key in-flight 去重 + 网络/磁盘移出全局锁;HTTP Fetcher 读取阶段 Content-Length 拒绝 + `io.LimitReader` 硬上限;**内存层加容量/LRU 上限,`SetItems` 剪枝 `shell.icons`**;补并发/取消/有界读取/内存上限测试。 +2. **[正式图标接入前 · 契约]** 后台结果经事件队列回 UI goroutine 后再 `ApplyIcon`/`Invalidate`,禁止后台直接改 shell map;补 `go test -race` 或等价单线程契约测试。 +3. **[防漂移]** modern/win7 适配器交互契约测试,验证 Gio 事件接线/虚拟化/上下文保持,不重复证明共享 ViewModel。 +4. **[API 健壮性]** `VisibleItems` 快照生命周期:refilter 原子替换新快照,避免每帧复制。 +5. **[可维护性 · 低优先]** 增视图前按职责拆两个 `shell.go`。 +6. **[观察项]** `ErrIconCacheUnsafe` 上层诊断/人工清理指引;自动 quarantine 另做威胁模型裁定。 + +> 裁定:本轮 Codex 复核成立且优于原审核——撤销原审核 stale 的 P3、纠正两处不精确表述、sharpen 三处落地方案。保留 Phase 2"可信基线"结论,T-201~T-204 不需回滚;新增"内存层无界"一项并入处理顺序第 1 步。 + +## 任务落地追踪 + +- `T-606` 已按最终处理顺序第 1 项落成:按 key in-flight 去重、Fetcher 流式有界读取、memory LRU 与 modern/win7 `shell.icons` 剪枝合并收口。 +- UI 线程投递、适配器交互契约、`VisibleItems` 快照、`shell.go` 拆分和 unsafe cache 诊断尚未编号;必须等待 T-606 完成并提交后再按顺序落成。 diff --git a/docs/tasks/T-606.md b/docs/tasks/T-606.md new file mode 100644 index 0000000..5c9f527 --- /dev/null +++ b/docs/tasks/T-606.md @@ -0,0 +1,90 @@ +--- +id: T-606 +title: 收紧图标缓存并发与内存边界 +phase: 2 +deps: [T-204, T-605] +status: TODO +created: 2026-07-17 +issue: null +context_ref: null +claim_branch: null +work_branch: null +write_paths: + - docs/tasks/T-606.md + - core/catalog/ + - app-modern/ui/gio/ + - app-win7/ui/gio/ + - docs/api.md + - docs/routes.md + - docs/04-architecture.md + - docs/05-coding-rules.md + - docs/review/phase2-review.md + - docs/00-ai-start-here.md + - docs/06-tasks.md + - docs/current-state.md +--- + +## 问题 / 背景 + +Phase 2 交叉审核确认,T-204 的 `IconCache.Load` 从取得全局 mutex 到返回全程持锁,锁内包含磁盘读取、图片校验、远端 Fetch 和磁盘原子写。一个慢图标会阻塞所有其他 key,包括本应立即返回的内存命中;简单地把 Fetch 移出锁又会使同一 key 重复下载并竞争写盘。 + +现有 `IconFetcher` 直接返回完整 `[]byte`,`IconCache` 只能在 Fetch 完成后检查 2 MiB 上限。该检查能阻止超大对象进入缓存,却不能阻止未来网络 Fetcher 先下载并分配任意大响应。与此同时,`IconCache.memory` 没有容量/LRU 上限,modern/win7 的 `shell.icons` 也不会在 Catalog 移除 app 后剪枝,长期运行时内存只增不减。 + +当前 `IconCache.Load` 尚无生产调用者,T-204 也明确把真实图标分发映射与网络装配留给后续,所以这些不是已经复现的滚动验收失败。它们仍是正式图标后台加载接入前必须关闭的并发与资源边界。 + +## 方案 + +1. 把 `IconFetcher` 调整为可有界读取的流式响应合约,由 `IconCache` 掌握读取边界与关闭责任: + - 响应提供 `io.ReadCloser` body 和可选的声明长度;未知长度使用明确哨兵值。 + - 声明长度超过 `maxBytes` 时在读取 body 前拒绝。 + - 对所有响应使用 `io.LimitReader(maxBytes+1)` 或等价逻辑;实际读到上限加一字节时返回 `ErrIconTooLarge`。 + - 成功、校验失败、读取失败、context 取消等所有路径都关闭 body,并保留关闭错误的既定错误优先级。 + - 不在本任务猜测 Catalog 内容哈希到 URL/DPI 变体的映射,也不实现具体发布端协议;后续 HTTP 适配器必须使用该流式合约。 +2. 为 `IconCache` 建立 Go 1.20 兼容的按 cache key in-flight 去重: + - 全局 mutex 只保护 memory/LRU/in-flight 元数据,不横跨磁盘、网络、图片解码或磁盘写入。 + - 同一 digest+DPI 只允许一个 leader 执行 disk→fetch→validate→store,其余调用者等待并复用结果。 + - 不同 key 可以并行,慢 key 不阻塞另一 key 的 memory hit。 + - follower 的 context 取消只停止自身等待,不得取消 leader;leader 失败/取消后发布同一结果、移除 flight,后续调用可重试。 + - 每个调用者取得独立的结果字节副本,不能修改 cache 或其他等待者观察到的内容。 +3. 将内存层改为按 key 的 LRU,同时限制总字节与条目数: + - 默认上限冻结为 32 MiB、256 个 key;命中提升 recency,插入/替换时更新准确字节计数并持续淘汰最旧项直到两个上限同时满足。 + - 同一 digest 的不同 DPI 仍是不同 key;磁盘缓存与现有内容校验/原子写语义不变。 + - LRU 操作与 in-flight 完成发布在同一同步纪律下,不得产生负计数、重复 list 节点或返回已被复用的内部切片。 +4. modern/win7 的 `AppShell.SetItems` 按新 Catalog app ID 集合剪枝 `shell.icons`,保留仍存在 ID 的 `paint.ImageOp`;与现有 rows/categories 剪枝语义对齐。 +5. 扩展并发、流边界、LRU 与双 Gio 测试,使用 channel/barrier 等确定性同步,不以 `time.Sleep` 猜测并发时序。 +6. 同步图标缓存协议、架构与编码规则,明确“内存→磁盘→流式 Fetcher”的边界、内存容量、按 key 去重以及真实 UI 接入仍须走 application event/UI goroutine。 + +## 验收要点 + +- 一个阻塞的远端 key 不会阻塞另一 key 的内存命中;两个不同远端 key 能实际重叠执行。 +- 同一 key 的并发 miss 只执行一次 Fetch 和一次成功存储,所有等待者获得内容相同但 backing array 独立的结果。 +- follower context 取消能及时返回且不影响 leader;leader 失败或取消后 in-flight 被清理,下一次调用可以重新 Fetch。 +- 声明 `Content-Length` 超过 2 MiB 默认上限时不读取 body;未知/伪造较小长度但实际 body 超限时最多读取 `maxBytes+1`;所有路径关闭 body。 +- 哈希不符、非法图片、磁盘损坏在线修复、离线读取已验证磁盘缓存、DPI 分键和非致命磁盘写 warning 的既有语义保持通过。 +- 默认 memory LRU 同时受 32 MiB 与 256 key 限制;命中提升顺序,替换正确更新字节数,淘汰后可从磁盘/远端重新装入。 +- modern/win7 `SetItems` 删除 app 后对应 `shell.icons` 项消失,保留 app 的 ImageOp 不变;rows/categories 既有稳定性测试继续通过。 +- 并发测试使用确定性同步并可重复运行;若当前工具链支持 race detector,执行 `go test -race` 并记录结果,不把 race 支持作为 Win7 交叉构建的前提。 +- Go 1.20.14 + `GOWORK=off` 下 `go vet ./...`、`go test -count=1 ./...` 通过。 +- modern 根 workspace 与 win7 独立 workspace 的 `ui/gio` 测试和 Windows amd64 构建通过;`./scripts/verify_phase0.ps1` 全绿。 +- `python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py` 与提交前差异检查通过。 + +## 边界(不改什么) + +- 不决定 `sha256:` 图标引用到下载 URL、CDN 路径或 DPI 变体的发布端映射,不增加未经协议冻结的 Catalog 字段。 +- 不把 `IconCache` 正式装配进应用启动或列表滚动链路;后台事件→UI goroutine→`ApplyIcon`/Invalidate 的接线按审核顺序另立后续任务。 +- 不补 modern/win7 完整适配器交互契约测试,不修改 `VisibleItems` 快照 API,不拆分 `shell.go`;这些按审核后续顺序分别处理。 +- 不对磁盘图标缓存实现 LRU/容量清理,不自动删除或 quarantine symlink/reparse point;`ErrIconCacheUnsafe` 继续 fail closed。 +- 不引入 `x/sync/singleflight` 或其他第三方缓存库,不升级 Go/Gio,core 与 win7 继续保持 Go 1.20 兼容。 +- 不处理 ZIP 中央目录预扫描、断电耐久、Catalog 签名向量或 T-302 安装整合。 +- 不修改、提交或删除用户的 `soft_quay.code-workspace`。 + +## 协作约束 + +- 按仓库当前规则由单 Agent 串行执行,不启动子 Agent。 +- 本任务只允许修改 frontmatter 中的 `write_paths`;若流式 Fetcher 需要新增公共协议字段或真实 URL 规则,必须停止并记录,不得在 T-606 内猜测协议。 +- T-606 完成、完整验证并提交前,不落成或领取 Phase 2 审核顺序中的后续任务。 + +## 执行记录 + +- 2026-07-17:根据 `docs/review/phase2-review.md` 交叉复核定稿的第一优先级整改落成任务;现有全局最大任务为 T-605,因此取 T-606。 +- 2026-07-17:任务合并收口按 key in-flight、流式有界读取、memory LRU 与双 shell 图标剪枝;UI 线程投递、适配器契约、VisibleItems 快照和文件拆分继续按审核顺序串行拆分。