From 171572973b0db2345b11d3ebdf2d23f2e7bbc049 Mon Sep 17 00:00:00 2001 From: ila Date: Fri, 17 Jul 2026 17:31:46 +0800 Subject: [PATCH] Stabilize visible item snapshots (T-609) --- core/application/catalog_list.go | 10 +- core/application/catalog_list_test.go | 148 ++++++++++++++++++++++++++ docs/00-ai-start-here.md | 4 +- docs/04-architecture.md | 2 +- docs/05-coding-rules.md | 1 + docs/current-state.md | 14 +-- docs/review/phase2-review.md | 4 +- docs/routes.md | 2 + docs/tasks/T-609.md | 13 ++- 9 files changed, 180 insertions(+), 18 deletions(-) diff --git a/core/application/catalog_list.go b/core/application/catalog_list.go index e89fc6b..a140c8e 100644 --- a/core/application/catalog_list.go +++ b/core/application/catalog_list.go @@ -109,7 +109,10 @@ func (model *CatalogListModel) ResetFilters() { model.refilter() } -// VisibleItems returns the current immutable-by-convention snapshot. +// VisibleItems returns the current read-only snapshot generation without copying. +// The snapshot remains stable after later model changes. Callers must not modify +// its elements, nested Tags, or capacity; CatalogListModel is single-owner and +// does not support concurrent reads and writes. func (model *CatalogListModel) VisibleItems() []CatalogListItem { return model.visible } @@ -162,7 +165,7 @@ func (view CatalogView) Valid() bool { } func (model *CatalogListModel) refilter() { - model.visible = model.visible[:0] + visible := make([]CatalogListItem, 0, len(model.items)) for _, item := range model.items { if model.category != "" && item.Category != model.category { continue @@ -173,8 +176,9 @@ func (model *CatalogListModel) refilter() { if model.query != "" && !matchesQuery(item, model.query) { continue } - model.visible = append(model.visible, item) + visible = append(visible, item) } + model.visible = visible } func matchesView(item CatalogListItem, view CatalogView) bool { diff --git a/core/application/catalog_list_test.go b/core/application/catalog_list_test.go index 564ae29..9ad1269 100644 --- a/core/application/catalog_list_test.go +++ b/core/application/catalog_list_test.go @@ -1,11 +1,14 @@ package application import ( + "reflect" "testing" "softbox.local/core/domain" ) +var visibleSnapshotSink []CatalogListItem + func TestCatalogListModelCombinesSearchCategoryAndView(t *testing.T) { model := NewCatalogListModel([]CatalogListItem{ { @@ -98,6 +101,151 @@ func TestCatalogListModelCategoriesAndReset(t *testing.T) { } } +func TestCatalogListModelVisibleSnapshotsSurviveModelChanges(t *testing.T) { + tests := []struct { + name string + prepare func(*CatalogListModel) + mutate func(*CatalogListModel) + }{ + { + name: "query", + mutate: func(model *CatalogListModel) { model.SetQuery("image") }, + }, + { + name: "category", + mutate: func(model *CatalogListModel) { model.SetCategory("图像") }, + }, + { + name: "view", + mutate: func(model *CatalogListModel) { model.SetView(CatalogViewUpdates) }, + }, + { + name: "reset", + prepare: func(model *CatalogListModel) { + model.SetQuery("image") + }, + mutate: func(model *CatalogListModel) { model.ResetFilters() }, + }, + { + name: "items", + mutate: func(model *CatalogListModel) { + model.SetItems([]CatalogListItem{ + {ID: "new-app", Name: "New", Category: "其他", Tags: []string{"new"}}, + }) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + model := NewCatalogListModel(catalogSnapshotFixture()) + if test.prepare != nil { + test.prepare(model) + } + previous := model.VisibleItems() + if len(previous) == 0 { + t.Fatal("test setup produced an empty previous generation") + } + wantPrevious := cloneSnapshotForTest(previous) + + test.mutate(model) + + if !reflect.DeepEqual(previous, wantPrevious) { + t.Fatalf("previous generation changed:\n got: %#v\nwant: %#v", previous, wantPrevious) + } + current := model.VisibleItems() + if len(current) == 0 { + t.Fatal("test mutation produced an empty current generation") + } + if &previous[0] == ¤t[0] { + t.Fatal("current generation reused the previous backing array") + } + }) + } +} + +func TestCatalogListModelVisibleItemsDoesNotCopyWithinGeneration(t *testing.T) { + model := NewCatalogListModel(catalogSnapshotFixture()) + first := model.VisibleItems() + second := model.VisibleItems() + if len(first) == 0 || len(second) == 0 { + t.Fatal("test setup produced an empty generation") + } + if &first[0] != &second[0] { + t.Fatal("repeated VisibleItems calls copied the current generation") + } + + model.SetQuery(" ") + unchanged := model.VisibleItems() + if &first[0] != &unchanged[0] { + t.Fatal("no-op model update published a new generation") + } + if allocations := testing.AllocsPerRun(100, func() { + visibleSnapshotSink = model.VisibleItems() + }); allocations != 0 { + t.Fatalf("VisibleItems allocations per read = %v, want 0", allocations) + } + + model.SetQuery("image") + changed := model.VisibleItems() + if len(changed) == 0 { + t.Fatal("changed generation is empty") + } + if &first[0] == &changed[0] { + t.Fatal("actual model update did not publish a new generation") + } +} + +func TestCatalogListModelVisibleSnapshotsCoverEmptyAndRestore(t *testing.T) { + model := NewCatalogListModel(nil) + if visible := model.VisibleItems(); len(visible) != 0 { + t.Fatalf("empty catalog visible items = %#v", visible) + } + + model.SetItems(catalogSnapshotFixture()) + full := model.VisibleItems() + wantFull := cloneSnapshotForTest(full) + model.SetQuery("missing-app") + if visible := model.VisibleItems(); len(visible) != 0 { + t.Fatalf("no-match visible items = %#v", visible) + } + if !reflect.DeepEqual(full, wantFull) { + t.Fatalf("full generation changed after empty filter:\n got: %#v\nwant: %#v", full, wantFull) + } + + model.ResetFilters() + assertVisibleIDs(t, model, "json-parser", "image-tool", "log-viewer") + if !reflect.DeepEqual(full, wantFull) { + t.Fatalf("full generation changed after reset:\n got: %#v\nwant: %#v", full, wantFull) + } +} + +func catalogSnapshotFixture() []CatalogListItem { + return []CatalogListItem{ + { + ID: "json-parser", Name: "JSON Parser", Category: "开发", + Tags: []string{"json", "format"}, Status: domain.StatusInstalled, Installed: true, + }, + { + ID: "image-tool", Name: "Image Tool", Category: "图像", + Tags: []string{"png", "compress"}, Status: domain.StatusUpdateAvailable, Installed: true, + }, + { + ID: "log-viewer", Name: "Log Viewer", Category: "开发", + Tags: []string{"log", "diagnostic"}, Status: domain.StatusNotInstalled, + }, + } +} + +func cloneSnapshotForTest(items []CatalogListItem) []CatalogListItem { + cloned := make([]CatalogListItem, len(items)) + for index, item := range items { + cloned[index] = item + cloned[index].Tags = append([]string(nil), item.Tags...) + } + return cloned +} + func assertVisibleIDs(t *testing.T, model *CatalogListModel, want ...string) { t.Helper() visible := model.VisibleItems() diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 2a787fe..ccde0b0 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-608`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线与双适配器交互契约均已关闭;T-609 已落成待领取,下一步修正 `VisibleItems` 快照生命周期,其余审核整改与 Phase 1 中央目录预扫描继续串行处理,T-302 暂后置。 +当前项目已完成 Phase 0~2、T-301 与审核整改 `T-604`~`T-609`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约与 `VisibleItems` 快照生命周期均已关闭;下一步按 Phase 2 交叉审核顺序落成 modern/win7 `shell.go` 职责拆分任务,其余审核整改与 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. 已完成 T-606/T-607/T-608:图标缓存资源边界、Load/Decode→application event→有界 relay/Invalidate→UI ApplyEvent 线程接线,以及双 Gio 适配器的 Editor/Clickable、AppID、viewport、详情上下文与控件生命周期契约;下一步实现 T-609 的 `VisibleItems` 快照代际修复,再串行处理其余整改与 T-302/T-303、Phase 4-6。 +5. 已完成 T-606~T-609:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约,以及“变更时发布新 backing、同 generation 读取零复制”的 `VisibleItems` 生命周期;下一步落成双端 `shell.go` 职责拆分,再串行处理其余整改与 T-302/T-303、Phase 4-6。 ## 领取任务规则 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 8657bc8..167018d 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -59,7 +59,7 @@ UI 固定交互模式: 控件状态按**软件 ID**保存,不按列表序号;列表用惰性 `layout.List`;图标走内存 + 磁盘缓存。 -T-203 已把共享列表状态落在 `core/application.CatalogListModel`:源快照、搜索、单分类、all/installed/updates 视图和 selected app ID 都是无 IO 纯内存状态。两个 Gio 适配分别保存 Editor、`layout.List` 与以 app ID 为键的 Clickable;主循环或后台用例通过 `SetItems` 替换准备好的快照,Layout 不扫描 installed-app.json、不获取 Catalog。T-608 在两个隔离 workspace 以同场景交互契约验证 Editor/Clickable 经 `Layout`/`drainInput` 更新共享 model、重排后行点击仍按 AppID、关闭详情保留筛选与列表位置、500 项只布局 `layout.List.Position.Count` 所示可见子集,并通过语义树区分空 Catalog 与过滤无结果;不重复 ViewModel 纯逻辑。 +T-203 已把共享列表状态落在 `core/application.CatalogListModel`:源快照、搜索、单分类、all/installed/updates 视图和 selected app ID 都是无 IO 纯内存状态。两个 Gio 适配分别保存 Editor、`layout.List` 与以 app ID 为键的 Clickable;主循环或后台用例通过 `SetItems` 替换准备好的快照,Layout 不扫描 installed-app.json、不获取 Catalog。T-608 在两个隔离 workspace 以同场景交互契约验证 Editor/Clickable 经 `Layout`/`drainInput` 更新共享 model、重排后行点击仍按 AppID、关闭详情保留筛选与列表位置、500 项只布局 `layout.List.Position.Count` 所示可见子集,并通过语义树区分空 Catalog 与过滤无结果;不重复 ViewModel 纯逻辑。T-609 让每次实际 refilter 在局部新 backing array 完整构造后发布 `VisibleItems` generation,旧 generation 可安全保留到后续帧且每帧读取不复制;返回值严格只读,model 仍由单 owner goroutine 串行操作,不承诺并发安全。 T-204/T-606/T-607 图标链路为 `Catalog icon digest + DPI → 32 MiB/256-key memory LRU → verified disk → 流式 IconFetcher(maxBytes+1) → SHA-256/图片资源限制校验 → 原子磁盘缓存 → 后台 DecodeIcon → IconReady/IconFailed application event → bounded FIFO relay + Window.Invalidate → Frame/UI ApplyEvent → ApplyIcon(paint.ImageOp)`。同一 key 由一个 in-flight leader 去重,不同 key 的磁盘/网络工作并行;全局锁只保护 memory/LRU/in-flight 元数据。relay 队列满时无损背压且可由 context/close 取消,后台从不修改 shell map。UI 只接受当前 app 最新且 icon_ref/DPI 匹配的 request_id;删除 app、替换 IconRef 或取消会使迟到结果失效,替换 IconRef 同时清除旧 ImageOp。磁盘与远端都重新校验,断网只使用已验证磁盘缓存;详情右栏只读取 `CatalogListModel.SelectedItem` 与内存 ImageOp,关闭详情不清空筛选或列表位置。 diff --git a/docs/05-coding-rules.md b/docs/05-coding-rules.md index ce658b6..679ccc5 100644 --- a/docs/05-coding-rules.md +++ b/docs/05-coding-rules.md @@ -26,6 +26,7 @@ - Gio 代码只出现在 `ui/gio/`;Windows 调用只出现在 `platform/windows/`,且必须有非 Windows stub,保证 `go test ./...` 在 Linux CI 可跑。 - 仅 Win10+ 存在的 Windows API 必须 LoadLibrary 动态加载、失败降级,不得成为 EXE 导入表强依赖。 - Gio Layout 每帧禁止 IO(磁盘/网络/哈希/图片解码);后台任务只发布 application.Event,不得直接调用 `ApplyIcon` 或改控件/map。后台 event pump 只入有界 relay 并调用 `Window.Invalidate`;只有 Frame/UI goroutine可以 drain `ApplyEvent`。relay 满队列不得静默丢事件,关闭/取消必须解除背压等待。 +- `CatalogListModel.VisibleItems()` 返回当前只读 snapshot generation:refilter 只在状态变化时构造新 backing array 后发布,同 generation 的每帧读取不得复制;调用方不得修改 slice/item/Tags。旧 generation 在后续 model 变化后保持稳定,但 model 仍是单 owner、非并发安全对象。 - 图标 Fetcher 必须返回与 context 绑定的流,由 `IconCache` 在分配完整响应前执行声明长度拒绝与 `maxBytes+1` 有界读取;不得恢复为先读任意大 `[]byte` 再校验。缓存并发只允许按 key 去重,不得用横跨磁盘/网络的全局锁换取去重。 ## 3. 安全纪律(违反即安全事故) diff --git a/docs/current-state.md b/docs/current-state.md index cdee4f1..1be10d8 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -13,22 +13,22 @@ ## 当前快照 - 日期:2026-07-17 -- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604~T-608 已完成,T-609 已落成待领取,T-302 继续暂后置 +- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604~T-609 已完成,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 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、图标请求身份与 UI-only ApplyEvent/ApplyIcon -- 测试:core 覆盖 Catalog、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性、列表/图标并发/取消/读取边界/LRU、图标事件身份/失败分类/relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、最新/取消/换引用图标结果与平台 stub;安装恢复矩阵保持通过 +- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略、安全 ZIP 解压/回滚原型、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、图标请求身份与 UI-only ApplyEvent/ApplyIcon +- 测试:core 覆盖 Catalog、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性、图标并发/取消/读取边界/LRU、图标事件身份/失败分类/relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、最新/取消/换引用图标结果与平台 stub;安装恢复矩阵保持通过 - 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵 - 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令) - 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1` - 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交 -- 当前 blocker:无;下一步领取 T-609,修正 `VisibleItems` 快照生命周期;Phase 1 中央目录预扫描等继续串行,T-302 继续后置 +- 当前 blocker:无;下一步按 `docs/review/phase2-review.md` 最终顺序落成 modern/win7 `shell.go` 职责拆分任务;Phase 1 中央目录预扫描等继续串行,T-302 继续后置 ## 当前目录要点 | 路径 | 状态 | 说明 | | --- | --- | --- | | `docs/` | 已有 | harness coding 文档集(本次初始化完成) | -| `docs/tasks/` | 已有 | Phase 0~2、T-301 与 T-604~T-608 已完成;T-609 已落成待领取,其余审核整改尚未编号,T-302 暂后置 | +| `docs/tasks/` | 已有 | Phase 0~2、T-301 与 T-604~T-609 已完成;其余审核整改尚未编号,T-302 暂后置 | | `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 | | `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 | | `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp | @@ -40,9 +40,9 @@ 任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要: -- 已完成: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-608`。 +- 已完成: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-609`。 - 正在进行:无。 -- 下一个可领取任务:`T-609`(修正 `VisibleItems` 快照生命周期),依赖 `T-608` 已完成。 +- 下一个可领取任务:无;先按 `docs/review/phase2-review.md` 最终顺序落成 modern/win7 `shell.go` 职责拆分任务。 ## 当前可运行内容 diff --git a/docs/review/phase2-review.md b/docs/review/phase2-review.md index bd21283..9c64cf0 100644 --- a/docs/review/phase2-review.md +++ b/docs/review/phase2-review.md @@ -286,5 +286,5 @@ modern/win7 的 `ApplyIcon` 都直接写 `shell.icons` map,Layout 同时读取 - `T-606` 已完成最终处理顺序第 1 项:按 key in-flight 去重、Fetcher 流式有界读取、32 MiB/256-key memory LRU 与 modern/win7 `shell.icons` 剪枝已实现并通过完整双 workspace 闸门。 - `T-607` 已完成最终处理顺序第 2 项:后台图标 Load/Decode 只发布强类型 application event,有界 FIFO relay 无损背压并请求重绘,由 Gio Frame/UI goroutine drain 后执行 `ApplyEvent`/`ApplyIcon`;最新请求、删除 app、IconRef/DPI 变化和取消都阻断迟到结果回写。core 与双 workspace 定向测试及完整闸门通过。 - `T-608` 已完成最终处理顺序第 3 项:modern/win7 使用除 edition/窗口尺寸外一致的场景矩阵,验证 Editor/Clickable 经 Layout 更新共享 model、重排后 AppID 行身份、详情关闭上下文、500 项 viewport、app/category controls 释放及两类空状态语义;双端定向重复测试与完整闸门通过,生产 `shell.go` 无需修正。 -- `T-609` 已按最终处理顺序第 4 项落成待领取:`refilter` 在 model 变化时构造新外层快照后替换,旧 generation 保持稳定,`VisibleItems()` 每帧读取不复制;不扩展为并发安全或防御性深拷贝。 -- `shell.go` 拆分和 unsafe cache 诊断尚未编号;必须等待 T-609 完成并提交后再按顺序落成。 +- `T-609` 已完成最终处理顺序第 4 项:`refilter` 在局部新 backing array 完整构造后发布,旧 generation 跨五类公开 model mutation 保持稳定;同 generation 读取共享 backing 且零分配。core 定向重复测试、双 Gio 回归与完整闸门通过,未扩展为并发安全或防御性深拷贝。 +- `shell.go` 拆分和 unsafe cache 诊断尚未编号;下一任务从双端 `shell.go` 职责拆分开始,继续按顺序串行落成。 diff --git a/docs/routes.md b/docs/routes.md index f74b3c3..ba9140b 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -63,6 +63,8 @@ T-203 已落地的列表交互约束: T-608 用 modern/win7 同场景适配器契约固定上述接线:Editor 和视图/分类/行/恢复/关闭 Clickable 必须经 `Layout`/`drainInput` 更新共享 model;Catalog 重排后行事件仍按 AppID 选择,关闭详情保留筛选与 list First/Offset。500 项有限 viewport 同时核对实际布局计数与 `layout.List.Position.Count`;空 Catalog、过滤无结果及恢复按钮通过 Gio 语义树区分。两端测试只在 edition/窗口尺寸上保留真实差异,不互相 import Gio。 +T-609 固定 `VisibleItems()` 的只读 snapshot generation:筛选或 Catalog 变化时由 model 完整构造新 backing array 后发布,已被上一帧持有的旧 generation 保持稳定;同一 generation 的每帧读取不复制。两个 Gio 适配仍只在 UI owner goroutine 读取,不得修改返回 slice、item 或 Tags,该契约不提供并发读写安全。 + T-204 已落地的详情/图标约束: - 点击软件行用 selected app ID 打开右侧详情,关闭后回到同一列表/筛选/滚动上下文。 diff --git a/docs/tasks/T-609.md b/docs/tasks/T-609.md index fb251ab..6d7f01c 100644 --- a/docs/tasks/T-609.md +++ b/docs/tasks/T-609.md @@ -3,12 +3,12 @@ id: T-609 title: 修正 VisibleItems 快照生命周期 phase: 2 deps: [T-608] -status: TODO +status: DONE created: 2026-07-17 issue: null -context_ref: null +context_ref: ed9ded21109ec36373ed0f13fa42d1ebf55a5bbf claim_branch: null -work_branch: null +work_branch: agent/codex/T-609 write_paths: - docs/tasks/T-609.md - core/application/ @@ -80,3 +80,10 @@ write_paths: - 2026-07-17:根据 `docs/review/phase2-review.md` 交叉复核定稿的第四优先级整改落成任务;现有全局最大任务为 T-608,因此取 T-609,依赖已完成的 T-608。 - 2026-07-17:代码图确认 `VisibleItems` 有 core 测试与 modern/win7 Layout/适配器契约等 8 个调用点,当前为直接返回;五个公开 mutation 入口最终调用 `refilter`,而 `refilter` 明确以 `model.visible[:0]` 重用上一代 backing array。 - 2026-07-17:任务采用审核定稿方案 1:状态变化时构造并替换新外层快照,每帧读取不复制;只读约定覆盖 slice 元素与嵌套 tags,但不把本任务扩展为并发安全或防御性深拷贝。 +- 2026-07-17:在 `agent/codex/T-609` 分支领取任务,基线为 `ed9ded21109ec36373ed0f13fa42d1ebf55a5bbf`;保持单 Agent 串行执行。 +- 2026-07-17:基线 `./init.ps1` 通过,包含治理/上下文/边界/依赖版本检查、Go 1.20.14 core vet/test、modern Go 1.25 与 win7 Go 1.20.14 的测试和 Windows amd64 构建。 +- 2026-07-17:先增加生命周期回归矩阵并在旧实现运行;query/category/view/reset/items 五条公开 mutation 路径均稳定复现旧 generation 被覆盖,实际状态变化后 backing array 地址也未变化,证明测试能捕获审核指出的问题。 +- 2026-07-17:`refilter` 改为在局部 `visible` 中完整构造新结果后一次赋给 `model.visible`,不再使用 `model.visible[:0]`;`VisibleItems` 仍直接 O(1) 返回当前 generation,注释明确旧 generation 稳定、slice/Tags 只读、model 单 owner 且不支持并发读写。 +- 2026-07-17:测试覆盖旧快照跨 SetQuery/SetCategory/SetView/ResetFilters/SetItems 后字段与 Tags 不变、非空 generation backing 分离、同 generation 重复读取共享 backing 且 `AllocsPerRun=0`、no-op setter 不换 generation、空 Catalog/无匹配/恢复顺序。Go 1.20.14 `go vet ./application` 与 `go test -count=10 ./application` 通过。 +- 2026-07-17:modern Go 1.25.0 与 win7 Go 1.20.14 的 `go test -count=5 ./ui/gio` 分别通过,证明两个 Gio 调用方继续使用原 `VisibleItems()` API,无需修改 shell 或适配器契约。 +- 2026-07-17:完整 `./scripts/verify_phase0.ps1` 通过,包含治理/上下文/边界/版本校验、Go 1.20.14 core vet/test、modern Go 1.25 与 win7 Go 1.20.14 的 UI/平台测试及 Windows amd64 构建;架构、路由、编码规则、审核追踪和当前状态已同步。