# 风险分析与抗风险设计 > 建立:2026-09-30 | 触发原因:**真机首次启动就卡住了**,而这个插件要奔着官方插件市场去 > 状态:**根因排查中**,本文先如实列出「已暴露的」与「还没暴露但一定会遇到的」风险 --- ## 一、已经真实暴露的问题(不是假设) ### 🐛 问题 1:真机首启 90 秒超时,且修复后**仍卡** **现象**(2026-09-30 08:36 首次真实启动): | 观察项 | 结果 | |:---|:---| | `POST /files/start` | HTTP 500,耗时 90.6 秒,`Everything 实例启动后未在 90 秒内就绪` | | Everything 进程 | **存在**(PID 56752,窗口标题正确) | | `es -get-everything-version` | ✅ 退出码 0,返回 `1.5.0.1423`(**IPC 握手是通的**) | | `es -get-result-count` / `-n 5 *` | ❌ **卡死不返回**(150 秒+) | | Everything CPU | **0%(完全空闲)**,内存 47 MB | | 库文件(`.db`) | **从未生成** | **已排除的原因**(都做了对照实验): | 假设 | 实验 | 结论 | |:---|:---|:---| | ini 编码错误(UTF-8 vs GBK) | 对比 Everything 自己写的 ini;GBK 解码确实乱码 | ❌ **不是主因** —— 纯英文范围 + 同样 UTF-8 编码完全正常 | | 中文路径 | 单独用 `中文测试` 目录做范围 | ❌ **不是** —— 中文目录 0.1 秒返回 2 行,索引正确 | | 范围里有不存在的目录 | 13 个里有 2 个已删除 | 🟡 **已修**(过滤),但修完**仍然卡** | | 库文件自引用(库在 `.dsh` 里) | 把库移到 `%LOCALAPPDATA%` | 🟡 **已修**,但修完**仍然卡** | | 等待时间不够 | 90 秒 → 300 秒 | 🟡 已调,但**不是时间问题** —— CPU 全程 0%,根本没在扫描 | **当前最强的两个待验证假设**: 1. **超长路径 / 特殊路径**:`.dsh` 下有大量 `node_modules\.pnpm\...` 深层嵌套, 路径可能超过 Windows 260 字符上限 → Everything 在 folder 模式下可能卡住 2. **某个特定目录**导致:需要二分法逐个排除(`.dsh` 单独 → 正常/卡?→ 缩小到子目录) > ⚠️ **诚实结论:根因尚未确定。** 在此之前**不能声称已经修好**。 --- ## 二、抗风险能力评估(按「会不会崩」排序) ### 🔴 P0:会让插件**不可用**的风险 | # | 风险 | 现状 | 需要什么 | |:--|:---|:---|:---| | 1 | **索引范围过大**(几十万文件)→ 启动超时 | ❌ 只调了超时,没解决 | **不阻塞启动**:后台建索引 + 前端显示进度;或**限制范围规模**并在 UI 上提示 | | 2 | **实例启动后不响应 IPC**(本次现象) | ❌ 无处理 | **启动健康检查要有超时兜底**:探测失败 → 明确报错 + 建议(缩小范围/查看日志),**不能挂死** | | 3 | **超长路径 / 特殊字符路径** | ❌ 未验证 | 需实验确认;若确认,需在范围计算时**剔除或告警** | | 4 | **杀毒软件拦截 `Everything.exe`** | ❌ 无处理 | 启动失败要能被识别(进程不存在 / 立即退出),给出**人话提示** | | 5 | **用户已有同名实例 `DSHArtifacts`** | 🟡 设计上隔离,未测 | 启动前检测同名实例并给出提示 | | 6 | **Everything 崩溃 / 被任务管理器杀掉** | ❌ 无处理 | 查询失败要能**自动重启实例**并重试一次 | ### 🟠 P1:会**伤害体验**但不会崩 | # | 风险 | 现状 | 需要什么 | |:--|:---|:---|:---| | 7 | 首次启动要等几分钟 | ❌ UI 只有「启动中」 | 显示**已索引数量**(`-get-result-count` 可轮询)/ 进度条 | | 8 | 库文件体积(41 万项可能几十 MB) | ⚠️ 未测 | 显示库大小;提供「清理索引」入口 | | 9 | 库文件损坏 | ❌ 无处理 | 删库重建的兜底路径 | | 10 | 权限不足的目录(系统目录) | ❌ 未验证 | Everything 一般会跳过;需实测确认不卡 | | 11 | 范围含网络盘 / 移动硬盘拔出 | ❌ 无处理 | 启动时校验目录可达;失效的跳过 | | 12 | 索引范围包含**索引自己的库/ini** | 🟡 库已移出,**ini 还在 `.dsh` 里** | ini 也应移出,或显式排除 | | 13 | 插件升级后旧实例残留 | ❌ 无处理 | 版本变化时先关旧实例 | ### 🟡 P2:长期/边缘 | # | 风险 | 现状 | 需要什么 | |:--|:---|:---|:---| | 14 | Everything 上游更新 / 许可变化 | ✅ MIT 实读确认,版本已锁 1.5.0.1423b | 记录来源与版本;升级需重新验证 | | 15 | 多用户共用一台机器 | ❌ 未考虑 | 实例名/库路径需按用户隔离(当前 `%LOCALAPPDATA%` 天然按用户) | | 16 | 磁盘满导致索引失败 | ❌ 无处理 | 写库失败要能报错 | | 17 | 非 Windows 平台 | ✅ **2026-10-01 已解决**(Windows + Linux 双后端,接口一致) | macOS 仍未适配/未验证;跨平台**数据迁移**仍会坏(`path` 是主键、无重映射) | | 18 | 内存占用(大范围索引) | ⚠️ 47 MB(小范围时) | Linux 内存后端按 `WALK_ITEM_LIMIT=300000` 封顶(约 60MB 上界),需实测大范围 | --- ## 三、上官方市场前必须补齐的 按优先级: 1. **【阻塞】根因排查**:确定「实例不响应 IPC」到底是什么导致 —— 否则用户一装就崩 2. **【阻塞】启动不能挂死**:任何异常都要在有限时间内变成**一句人话 + 一个可操作建议** 3. **【阻塞】优雅降级**:Everything 不可用(被杀毒拦、被删、平台不符)时, **产物库原有功能完全不受影响**,只是「文件」视图显示不可用原因 4. **【重要】范围规模化处理**:给出上限策略(超限时提示/自动收窄),并在 UI 显示范围规模 5. **【重要】安装即测用例**(写进 README/上架材料): - 空范围(没有任何产出) - 小范围(1 个目录) - 大范围(几十万文件) - 含已删除目录 - 含中文路径 - 用户已有 Everything 在跑 - 无管理员权限 --- ## 四、我打算怎么改(待你确认) **架构层面的调整**(不是再打补丁): 1. **启动永不阻塞请求** - `ensureReady` 立即返回「已开始启动」,真正的建索引进后台 - 前端轮询 `status`(含 `resultCount`)显示进度 2. **健康检查与自愈** - 探测超时 → 判为「不健康」→ 自动尝试重启一次(走 `es -exit`,不强杀) - 仍失败 → 记录 `lastError` 并**停止重试**(不无限循环烧资源) 3. **范围守卫** - 启动前对每个范围目录:存在性 + 可达性 + 规模预检 - 规模超阈值(如 20 万项)→ 在 UI 上警告并提供「收窄范围」入口 4. **隔离到底** - ini / 库 / 状态文件**全部**移出可能被索引的目录 5. **降级开关** - 设置项 `indexEnabled`(默认开);关掉后一切回到 v0.6.0 行为,零残留 --- ## 五、测试补齐计划 | 用例 | 方式 | |:---|:---| | 空范围 / 单目录 / 中文目录 | ✅ 已有(selftest) | | **大规模范围**(41 万项) | ⏳ 正在做(本次卡住的场景) | | **已删除目录混入** | ⏳ 手工构造 | | **超长路径** | ⏳ 手工构造 `\\?\` 与深层嵌套 | | 实例不响应 IPC | ⏳ 手工构造(启动后立刻查询) | | 杀毒拦截 | ⚠️ 难以自动化,写进人工验收清单 | | 无管理员权限 | ⏳ 需要非管理员会话 | | 用户已有 Everything | ⏳ 当前机器**正好有**(pcsuite 捆绑的那个),可实测 | --- ## 六、✅ 事故结论与根治(2026-09-30 当天完成) ### 最终结论 **问题不在我们的配置,而在 Everything 的 folder 模式。** 长时间二分定位(每一步都有对照实验,详见 `~/.dsh/scratch/夜间工作计划-20260930.md`): | 现象 | 触发条件 | 状态 | |:---|:---|:---| | **僵死** | 范围里含 **junction / 符号链接**(如 pnpm 的 `node_modules` junction) | 已定位到具体目录并复现 | | **活锁** | 排除上述触发点后仍会出现(CPU 100% 烧到 215 秒、库始终 0 KB) | 存在,触发组合尚未穷尽 | **关键教训**:folder 模式要 Everything **自己逐层遍历目录树**, 在真实用户目录结构下**无法保证可用** —— 这是**第三方组件的固有缺陷**, 不是靠排查就能穷尽规避的。 ### 根治方案:双模式(已实现) ``` 优先 NTFS 模式(读 MFT + ntfs_volume_include_onlys 限制范围) ├─ 成功 → 用(15~30 秒就绪,CPU 1.6s) └─ 失败(通常缺管理员权限)→ 自动回退 folder 模式(+ 全部加固) └─ 仍失败 → 明确报错 + 清理实例 + 不影响产物库其它功能 ``` 实测对比: | 指标 | folder | **NTFS** | |:---|:---|:---| | 就绪 | 永不 | **30 秒内** | | 查询 | 全超时 | ✅ 正常 | | 隐私(`path:C:\Windows`) | — | ✅ **0 匹配** | | ini 体积 | 10000+ 字符 | **1036 字符** | ### 这次暴露出的、已一并修掉的问题 | # | 问题 | 修法 | |:--|:---|:---| | 1 | 范围里有已删除的目录 | 计算范围时过滤不存在的路径 | | 2 | 库文件在索引范围内(自引用) | 库移到 `%LOCALAPPDATA%` | | 3 | `Dirent.isDirectory()` 对 junction 返回 false → 排除规则漏掉它们 | 同时判断 `isSymbolicLink()` | | 4 | 等待上限单一(90 秒) | 按模式分开:NTFS 60s / folder 300s | | 5 | 超时后僵死实例残留占资源 | 超时自动清理(**只杀自己记录 PID 的实例**) | | 6 | 回收站目录(`.trash-<日期>`)名字带日期抓不到 | 加前缀匹配规则 | | 7 | 测试复用真实实例导致假失败 | selftest 先关既有实例(实例隔离) | ### 仍然存在、需要长期盯的风险 1. **NTFS 模式需要管理员权限** —— 普通用户会走 folder 回退路径,而那条路**已知不可靠**。 → 长期方案候选:检测到无管理员时**默认只索引工作区**(范围更小、更可能成功), 并在 UI 上说明原因。 2. **folder 回退路径的稳定性** —— 已尽力加固(排除 + 规模守卫 + 超时清理), 但**不承诺一定可用**;必须以「超时 + 清理 + 明确报错」收尾,绝不能挂死用户。 3. **Everything 1.5 beta 的上游风险** —— 版本已锁定 `1.5.0.1423b`; 升级前必须重跑本页所有对照实验。 --- ## 七、今晚新增的五个功能:风险面(2026-09-30 夜间) > **为什么会有这一节**:依琪交代过「这个插件奔着录入官方插件去,**必须列出各种其他用户可能遇到的风险**, > 看看有没有抗风险能力不会崩」。白天那份只覆盖到索引层;**今晚新增的五个功能当时没有任何风险条目**。 > 这一节把它们补上。 > > **怎么读**: > - 每条固定五栏:**风险 → 触发条件 → 影响面 → 现有防线 → 缺口** > - **影响面**分三档:🔴 崩 / 🟠 脏数据·误导 / 🟡 只是难看 > - **凡是没实测过的,一律标「未验证」** —— 不要把推理写成结论 > - **没发现问题的类别,明确写「查过,没找到」**,不凑数 > > **⚠️ 阅读前提(2026-09-30 深夜补)**:**§7.1~7.8 是当晚的「修复前」快照**, > 有意保留原文(**过程也要留**)。其中列出的问题**当晚多数已修掉** —— > **当前状态一律以 §7.0 的表为准(每条都带 commit)**。 > 所以下面正文里的数字、以及「没有 X / X 不存在」这类断言, > **都要读成「当晚的实测」,不是现状**。 > (这条前提本身就是今晚的教训:**文档停在「未修」会让人以为没人管 —— 反过来, > 文档停在「有问题」而实际已经修了,同样会误导下一个读它的人。**) ### 7.0 先看这张表:需要动手的(按影响面排序) | # | 风险 | 影响面 | 状态 | |:--|:---|:---:|:---| | R-1 | 客户端**全局没有请求超时 / 取消** → 慢请求让界面无限 loading | 🔴 体验性「崩」(无法恢复) | ✅ **已修** `89536dd`(请求层 `fetchWithTimeout` + 12s abort + 超时与普通失败分流)| | R-2 | 整理建议的**撤销**把「尝试数」当「成功数」报告,并清掉重试凭据 | 🟠 脏数据·误导 | ✅ **已修** `89536dd`(如实计成功/失败 + 失败时**保留重试凭据**)| | R-3 | `batchRefine` 在**同一作用域重复定义两次** → 第一份是死代码 | 🟠 未来回归(当前行为正确) | ✅ **已修** `89536dd`(删死代码 + 断言「只出现 1 次」)| | R-4 | 语义搜索是**全表同步扫描**,无上限/无超时 → 库大了阻塞宿主事件循环 | 🟠 卡顿(随规模恶化) | ✅ **已修** `8482fb8`(每 2000 条 `setImmediate` 让出;**实测 320ms→63ms**,结果逐字节一致)| | R-5 | `/import` 接受**任意路径**;黑名单只比对根目录名 → 可把系统目录整批登记进库 | 🟠 库污染 / 信息暴露 | ✅ **已修** `8482fb8`(判据统一为「**凭据目录=任意层级 / 系统目录=仅盘符根下第一段**」,实测 10/10)+ **CSRF 双栅栏**(同 commit)| | R-6 | 导入时 `extractLocalText` 用**同步** `readFileSync` 逐文件读 → 大目录导入卡宿主 | 🟠 卡顿 | ✅ **已修,但主因与本节诊断不同** `4772018`:真实主因是 `save()` 的同步全量序列化 → `contentIndex` 上限 50000→8000(**29.1MB→5.1MB;阻塞 396–569ms→85–93ms**)+ 导入字节预算 | | R-7 | 「优先精化」的**撤销不是严格逆操作**(用请求 ids 而非实际成功 ids) | 🟠 可能误清用户早先的标记 | ✅ **已修** `89536dd`(**只在全成功时才建立凭据**;部分成功时如实说明「无法确定是哪几条」)| | R-8 | `marked` 字段缺失时**乐观假设全部成功** | 🟠 误导 | ✅ **已修** `89536dd`(缺字段时如实说「宿主没返回数量」且**不给凭据**)| | R-9 | 间距/字号**刻度没有测试护栏**(只有行高有) | 🟡 一致性靠自觉 | ✅ **已修** `a525231`(收紧 padding)+ `5afca9f`(**四条刻度断言进 `test/ui-spec`**,含形状断言 + 有界豁免)| | R-10 | 窄面板无 `max-width`、只有两个断点;`.5px` 发丝线在非整数缩放下不稳 | 🟡 只是难看 | **判断为不做**(ui-core:`.5px` 改 1px 是「**拿确定的退步换不确定的改善**」;`max-width` 收益极小)| > **⭐ 本表每个「已修」都带 commit** —— > 因为今晚立的规矩是「**数字 / 状态必须和哈希一起报**」,否则它只是一个「曾经成立过」的说法。 > > **⚠️ R-6 的教训单独记**:本文原诊断(「逐文件 `readFileSync` 是主因」)**经实测不成立** —— > 遍历本来就是 async,且**已每 200 个文件让出**。真实主因是**体积驱动的同步序列化**。 > ⇒ **「读代码猜瓶颈」不可靠;瓶颈要量。** --- ### 7.1 语义搜索(`/search` → `store.searchSemantic`) **R-4 🔴/🟠 全表同步扫描,无上限无超时** - **风险**:搜索是**遍历全部有效记录**,对每条记录在 6 个字段(含最长 5 万字符的 `contentIndex`) 上做 `indexOf` 扫描,**同步执行、没有条数上限、没有时间上限**。 - **触发条件**:库变大(几千条以上)或 `contentIndex` 普遍较长时;一次中文长查询会展开成多个 2-gram 词元, 词元数 × 记录数 × 字段数 同时放大。 - **影响面**:🟠 **卡宿主**(`searchSemantic` 是同步函数,跑在宿主 Node 事件循环上)→ 表现为整个 DSH 界面卡顿,不只是产物库卡。**不会崩,但会「像崩了」。** - **现有防线**:客户端 `SEMANTIC_LIMIT = 20` 限制了**返回条数**(不限制扫描量);200ms 防抖。 - **缺口**:**返回条数 ≠ 计算量** —— `limit` 是在全表扫描**之后**才 `slice` 的。 没有:扫描条数上限 / 时间预算 / 分片让出事件循环。 **未实测**:没有量过 1 万条时的真实耗时(当前库 200+ 条无感)。 **R-11 🟡 单字符英文/数字词元未被过滤 → 噪声命中** - **风险**:过滤规则是「长度 < 2 **且**不含 a-z0-9 就跳过」,也就是**只挡单字中文**; 单字符英文/数字(如 `a`、`1`)**会保留**并参与匹配。 - **触发条件**:查询里含单字符字母/数字(例如搜 `a 报告`)。 - **影响面**:🟡 结果里混进大量低分命中(与今晚刚修的 2-gram 污染**同一类**)。 - **现有防线**:加权排序会让它们排在后面;`matched` 会如实显示命中的是哪个词元。 - **缺口**:属于**同类残留**。**未实测**严重程度(没构造过对照用例)。 → 建议:照今晚修 2-gram 的同一手法补一个反例集再决定要不要改。 **R-12 🟡 词频累加会扭曲排名** - **风险**:命中计分用 `while (indexOf(term, idx+1))` 循环,**每出现一次就加一次权重**。 - **触发条件**:某个词元在某条记录里高频出现(长文档、重复路径)。 - **影响面**:🟡 排名偏向「啰嗦的记录」,而不是「最相关的记录」。 - **缺口**:没有「每字段每词元只计一次」的上限。**未验证**是否实际影响体感。 **R-13 🟠 命名与能力的落差:「语义」其实是词元/2-gram 子串加权** - **风险**:叫「语义搜索」,实现是**词元 + 中文 2-gram 的子串匹配 + 字段加权**, **没有向量/同义词语义**。搜「影片剪辑」不会命中标题为「视频素材」的记录。 - **影响面**:🟠 **误导**(用户会按「能按意思找」的预期使用,然后觉得「搜不到」)。 对上架材料尤其重要 —— **不要把它写成 embedding 语义检索**。 - **现有防线**:`matched` 会显示「因为命中哪个词元」;README 里我已按「词元/2-gram」描述。 - **缺口**:命名本身没法在 UI 上解释清楚 —— 建议评估是否改叫「模糊搜索 / 按词找」。 **✅ 查过、没找到缺口的**: - **结果点击后记录已被删**:`open()` 对结构化 `reason === 'artifact-not-found'`(兼容 404)有专门分支, 提示「这条记录已经不在了」;`expandAll()` 逐条计数 `missed` 并如实汇报。**防线完整。** - **搜索不可用时静默降级**:注释明确「用不了时**不会**偷偷给你关键词结果」,是**不**降级的设计。**符合预期。** --- ### 7.2 整理建议(`/suggest-cleanup` + 破坏性动作) **R-2 🟠(本文最实的一条)撤销把「尝试数」当「成功数」报告** - **风险**:撤销逻辑 `undoLast()` 对每个 id 执行反向操作,**逐项用 `.catch(function(){})` 吞掉失败**, 然后在收尾时 `notify("已撤销 " + ids.length + " 项")` —— **打印的是「尝试的条数」,不是「成功的条数」**; 紧接着 `setUndo(null)` **清掉撤销凭据**。 - **触发条件**:撤销过程中任意一项失败(记录已被别处改动/删除、宿主返回非 2xx)。 - **影响面**:🟠 **假成功报告**。用户被告知「已撤销 N 项」,实际可能有几条**仍留在回收站/归档**, 而且撤销按钮已经消失、**无法重试**。(记录本身没丢,还能去回收站找 —— 不是不可逆的数据丢失, 但用户对「现在是什么状态」的认知是错的。) - **现有防线**:**对照组说明这是遗漏而非设计** —— **同一文件里的动作路径 `runEach()` 是如实计失败并报告的**(「已把 N 项…(失败 M 项)」), 且只把**成功**的 id 写进撤销凭据。**只有撤销这一侧少了这两件事。** - **缺口**:撤销侧需要(①)计 `failed` 并如实报;②失败非零时**保留**撤销凭据供重试。 → **建议交 ui-core**(属 `lib/client.js`,我不改代码)。 **R-14 🟡 建议单是快照,动作按快照执行** - **风险**:建议单只在进入时取一次;用户看的时候,AI/其它入口可能已经改动了那些记录。 - **触发条件**:一边开着整理建议,一边让 AI 精化/删除记录。 - **影响面**:🟠 可能对**已经变化的对象**执行破坏性动作。 - **现有防线**:动作路径逐条计失败并如实汇报(见 R-2 的对照组)→ 失败会**显式**暴露,不会静默。 - **缺口**:没有「执行前重新校验一次」。 另有一个**未验证**的边界:重复组「保留最新那条」的 `keepId` 若在快照后被删, 客户端仍会把该组 `dupIds` 全部移入回收站 → 该路径可能一条记录都不剩(文件本体不动,记录在回收站可恢复)。 **✅ 查过、没找到缺口的**: - **二次确认**:破坏性按钮是「移入回收站」→「确认移入回收站 / 算了」两步,**有**。 - **每条的「为什么」**:重复组会显示「同一路径有 N 条(保留最新那条「X」)· 将处置:…」。**有。** - **僵尸项目**:只提示、不自动动(与后端注释一致)。**符合预期。** --- ### 7.3 标记优先精化(`/refine-request` + `/unrefine`) **R-3 🟠 `batchRefine` 在同一作用域重复定义两次** - **风险**:`PanelInner` 作用域里 **`function batchRefine(rows)` 定义了两遍**, 两份内容不同 —— **第二份多一行 `setRefineUndo(...)`**(撤销凭据)。 JS 里后定义者生效,所以**当前行为是对的**,但**第一份是死代码**。 - **触发条件**:任何人在**第一份**上做修改(它更靠上、更容易先被看到, 且它看起来「缺了撤销」很像一个待修的 bug)。 - **影响面**:🟠 **未来回归**。改第一份**不会有任何效果**, 而且不会有测试红 —— 这会浪费下一个人大量时间,也可能让某个修复「看起来做了其实没做」。 - **现有防线**:无。 - **缺口**:删掉其中一份(保留带 `setRefineUndo` 的那份)。 → **建议交 ui-core**。 - **补充**:我一开始把这份重复误读成「两个不同的调用点」—— **`/refine-request` 在文件里出现两次,其实是同一处逻辑的两份拷贝**。 **R-8 🟠 `marked` 缺失时乐观假设全部成功** - **风险**:`isFinite(Number(result.marked)) ? Number(result.marked) : ids.length` —— 响应里没有 `marked`(或非数字)时,**假定全部标记成功**并如实报「已标记 N 条」。 - **触发条件**:宿主返回形状异常(版本不匹配、代理改写、错误响应体)。 - **影响面**:🟠 误导(用户以为都标记上了)。 - **缺口**:兜底应偏保守(不确定就说「已发出请求,未能确认条数」)。 **R-7 🟠 「撤销」不是严格逆操作(未验证)** - **风险**:撤销凭据存的是**本次请求的 ids**,不是**实际标记成功的 ids** (对比:整理建议的 `runEach` 存的是**成功**的 id)。若这一批里有条目**本来就是优先标记状态**, 撤销会把它们一起取消 → **误清用户更早设置的标记**。 - **触发条件**:批量选择里混有已标记的条目,之后点撤销。 - **影响面**:🟠 状态被改错(可再次手动标记回来,代价是用户不知道发生了什么)。 - **缺口**:后端 `/refine-request` 只返回计数、不返回**哪些**被标记, 所以客户端**无法**精确构造逆操作。**未验证**:不确定实际会不会遇到混选场景。 → 若要修,可能需要后端返回 id 列表(**属 `lib/**`,请 lead 决定**)。 **✅ 查过、没找到缺口的(这一项做得最好)**: - **`unmarked` ≠ `targeted` 的语义边界**:撤销文案用 `unmarked`, 并用 `stillNeedsRefine` 补一句「其中 N 条按内容仍然需要精化」;注释明确写了 「**不能说『已移出待精化』**」。**没有夸大,是今晚少见的把边界写清楚的地方。** - **失败分支用结构化 `reason`**(不再按状态码文本匹配)。 --- ### 7.4 导入文件夹(`/import` → `store.importFolder`) **R-5 🟠 接受任意路径;黑名单只比对根目录名** - **风险**:`/import` 收一个**任意目录路径**就扫。它的访问控制是: ① 回环限制;② 根目录 **basename** 黑名单;③ 逐文件 `isSensitivePath`。 - **触发条件**:用户(或本机上的其它程序/网页)POST 一个不合适的路径。 - **影响面**:🟠 **库污染 + 信息暴露**:可以把系统目录整批登记成「资料」 (存路径、大小、以及 ≤5 万字符的**正文索引**)。 - **现有防线(都实测读过代码,确实存在)**: - **回环限制**:`if (!isLoopback(req))` 那段里 `req.method !== 'GET'` 直接判 `localOnly` → **非 GET 一律 403**, 所以**局域网来源打不了 `/import`**(`/import` 是 POST)。 - 根名黑名单:`.ssh / .gnupg / .aws / .azure / .kube / .docker / .npmrc / appdata / windows / system32 / .dsh` - 逐文件 `isSensitivePath`:文件名正则 + **路径中任意一段**命中凭据目录就拒 - 正文索引有界:**≤2MB** 才读、**≤50k 字符**才存、含 NUL 判为二进制不索引 - 单文件大小上限(`importMaxSizeMB`,默认 50MB)、`byPath` 去重、噪音目录/文件跳过 - **缺口**: 1. **根名黑名单只比对根目录的 basename** → `D:\作品\windows`、 `...\Documents\appdata` 这类**正常文件夹会被误拒**(**假阳性**,影响面 🟡); 反过来 `ProgramData`、`Program Files`、用户主目录**不在**黑名单里,可以整批导入。 2. **没有 `assertInScope`** —— 与 `/files/*`(有范围闸门 + 回环)不对称。这是**有意的** (导入本来就要允许任意文件夹),但意味着**「任意路径读取」这个能力是设计的一部分**, 上架材料与文档里应如实说明。 3. **CSRF / 本机网页(未验证)**:`webServer` 无鉴权中间件、无 CSRF token, 而 `readBody` **只做 `JSON.parse`,不校验 `Content-Type`**。 理论上一个恶意网页可以用 `Content-Type: text/plain`(**简单请求、不触发预检**) POST 一个 JSON body 到 `http://127.0.0.1:/ext/artifacts/import` → 浏览器的请求源就是回环, **能过 `isLoopback`**。**我没有实测过**(需要构造跨源页面 + 观察服务端行为), **所以标「未验证」,不要当结论**。若要收敛,最小的改法是校验 `Content-Type` + 加一个 CSRF token。 **R-6 🟠 导入过程中用同步 `readFileSync` 逐文件读正文** | ⚠️ **本条主因经实测不成立,见下方「后续」** - **风险**:`extractLocalText()` 用 **`fs.readFileSync`**(同步),对每个文本文件最多读 2MB。 - **触发条件**:导入含大量文本文件的目录(几千个 `.md`/`.txt`/代码)。 - **影响面**:🟠 **宿主卡顿**(每次同步读都阻塞事件循环)。 - **现有防线**:每 200 条 `await setImmediate()` 让出一次事件循环;单文件 ≤2MB;二进制跳过。 - **缺口**:**让出的是「条与条之间」,挡不住单次同步读的阻塞**。 没有:总耗时上限 / 改为 `await fs.promises.readFile` / 并发上限。 > 📌 **后续(2026-09-30,`4772018`)—— 实测推翻了本条的因果判断**: > **阻塞随字节数走,不随文件数走**(0.5MB→16ms / 3.4MB→19ms / **29.1MB→73ms**); > 遍历本来就是 async、且**已每 200 个文件让出** —— 所以「逐文件同步读」**不是主因**。 > **真实主因是 `save()` 的体积驱动同步全量序列化**,而 `contentIndex` 占库体积 **89%**。 > ⇒ 修的是**体积**(`contentIndex` 上限 50000→8000:**29.1MB→5.1MB,阻塞 396–569ms→85–93ms**), > 不是「逐文件读」。 > **教训:读代码猜瓶颈不可靠 —— 瓶颈要量。**(原文保留不删,正是为了留下这个反例。) **R-15 🟠 `deferSave` 期间的中断可能把「部分导入」持久化(推理,未实测)** - **风险**:整批导入用 `register(..., {deferSave:true})` 累积在内存、最后一次性 `save()`。 若中途失败或用户离开,`save()` 不执行 —— 但那些记录**已经写进内存的 `this.items`**, 之后**任意一次其它写操作触发的 `save()` 会把它们一起落盘**。 - **影响面**:🟠 用户以为「导入取消了/没成功」,库里却多出一批半截记录。 - **缺口**:没有「批量事务」语义 —— 失败时应回滚内存改动(或标记为未提交)。 - **注意**:这条是**读代码推出来的,没有实测**,请按「未验证」对待。 **R-16 🟡 相对路径按宿主 cwd 解析** - **风险**:客户端只 `trim()` 就直接发送,**不校验是否为绝对路径**;服务端 `path.resolve()` 会按**宿主的 cwd** 解析。 - **影响面**:🟡 用户输入 `Documents` 会导到 `/Documents`,与直觉不符(导错目录)。 - **缺口**:设置项 `indexExtraDirs` **强制绝对路径**,而 `/import` 不强制 —— **两处口径不一致**。 **R-17 🟡 网络盘 / UNC 未拒绝** - **风险**:`\\server\share` 这类路径没有专门处理;不可达共享上的 `readdir` 可能长时间阻塞。 - **缺口**:无超时、无「网络盘」识别(目录视图那边有 local-only 处理,**导入这边没有**)。 **R-18 🟡 符号链接/junction 目录被静默忽略、且计数不自洽** - **风险**:`e.isDirectory()` 对 junction/符号链接返回 false(我们白天在索引那边踩过同一个坑), 而 `e.isFile()` 也为 false → 两个分支都不进,**既不导入、也不计入 `skipped`**。 - **影响面**:🟡 `count + skipped ≠ 实际条目数`,用户对不上账(**不会**无限递归,这点是安全的)。 - **缺口**:要么显式计入 `skipped`,要么像索引那边一样同时判 `isSymbolicLink()`。 **R-19 🟡 功能缺口:面板的导入没有「项目名」输入** - **风险**:网页版导入有 `{dir, project}` 两个字段,**面板只发 `{dir}`** → 项目名只能取目录 basename,无法在导入时指定/归并到已有项目。 - **影响面**:🟡 能力回退(不是风险,是**功能差距**)。 - **缺口**:补一个 project 输入框(后端**已经支持** `project` 参数,无需改 `lib/**`)。 **✅ 查过、没找到缺口的**: - **敏感文件**:`isSensitivePath` 覆盖「文件名正则」+「任意路径段命中凭据目录」,**比只看扩展名严**。 - **大目录上限**:`maxFiles = 10000` 在遍历时**逐条检查**(`if (count >= maxFiles) return`),到达即停。**有。** - **库自身数据目录**:仅当数据目录在导入根内部时才排除(逻辑正确,注释也解释了为什么不能无条件排除)。 - **取消的措辞**:`pickDirectory()` 的取消与失败**都**落在 `.catch`(无法区分), 文案写成「已取消或没选到目录」—— **如实且中性**,没有把用户的取消说成错误。**处理得对。** --- ### 7.5 窄面板自适应 + 一致性(纯样式,全部 🟡) **R-10 🟡 两个断点 + 没有最大宽度** - **现状**:用 `@container`(`container-type:inline-size`,**按面板宽度而非视口** —— 这是正确做法), 断点只有 `max-width:520px`(搜索/输入框独占一行)与 `max-width:400px`(工具条间距)。 - **缺口**:**明确选择了「任何控件都不隐藏」**,所以 <400px 时压力落在**溢出/挤压**上(可能横向滚动)。 超宽方向**没有 `max-width`**,行与卡片会被拉得很长(可读性)。 - **影响面**:🟡 只是难看,**不会崩**。 **R-20 🟡 `.5px` 发丝线在非整数缩放/高 DPI 下不稳** - **风险**:官方规范要求的中性边框 `.5px`,在 125%/150% 缩放或高 DPI 下可能渲染成 0 或 1px(粗细不一)。 - **缺口**:未做 `min-resolution` / 缩放相关的降级。**未实测**(没有在 125% 缩放下逐项核对过)。 **R-9 🟡 间距/字号「刻度」没有测试护栏(这是 lead 点名的那条)** - **现状**(我扫了**全部 15 个测试文件**): - ✅ **行高有护栏**:`ui-spec.test.mjs` 会从行相关选择器里抽 `height:NNpx` 并校验在允许档位内。 - ❌ **gap / padding / font-size 刻度没有任何断言** —— 全库搜不到针对它们的校验。 - **实测当前值**(`lib/client.js`): - `font-size`:只有 **12 / 13** → **干净** - `gap`:2 / 4 / 6 / 8 / **10 / 12** → 6 是基础节奏(2/4/6/8),10 与 12 在节奏之外 - `padding`:2 / 4 / **5** / 6 / 8 / **10** / 12 / **14** / **24** / **44** / **52** (其中 24/44/52 多半是尺寸常量而非间距,**不能一概算违规**) - **影响面**:🟡 一致性只能靠自觉 —— **有人写死 on/off-scale 的值,测试不会红**。 - **缺口**:要么把刻度抽成常量 + 加断言,要么至少加一条「间距值必须 ∈ 允许集合」的检查。 → **这是「约定」与「护栏」的差别**;今晚 `46773a2` 已经做过一轮归一,**但没有任何东西防止它再退化**。 **✅ 查过、没找到缺口的**: - `prefers-reduced-motion` 有关照(两处)。 - 用容器查询而不是媒体查询 —— **跟随面板宽度**是对的(面板可能嵌在任意宽度的容器里)。 --- ### 7.6 跨功能:一整类共性风险 **R-1 🔴 客户端全局没有请求超时,也没有取消** - **风险**:`lib/client.js` 里**没有 `AbortController`、没有 fetch 超时** (全文搜过:只有若干 UI 用的 `setTimeout` 防抖/Toast 计时器,**没有一处给网络请求设超时**)。 - **触发条件**:任何慢或挂住的请求 —— 语义搜索的大库全表扫描(R-4)、 大目录导入(R-6)、Everything 索引未就绪、局域网被扩展拦截(lead 今晚在真机遇到过)。 - **影响面**:🔴 **体验性「崩」**:界面停在骨架屏/「处理中」,**既不会超时、也不会报错、也无法取消**。 用户唯一能做的是刷新 —— 而这正是 lead 今晚在局域网模式实测到的「骨架屏无限转」。 - **现有防线**:各视图有 error 分支,但**错误永远不会到**(请求既不 resolve 也不 reject)。 - **缺口**:给 `apiGet` / `apiSend` 统一加超时(`AbortSignal.timeout(ms)`)+ 超时后的明确文案与重试。 **这是今晚五个功能共同的地基问题**,建议优先于任何单点修复。 → 与 task-15(性能护栏:索引状态可见 / 可暂停 / 超时兜底)**同一个根**,但 task-15 仍 pending。 --- ### 7.7 我不确定、**明确标为未验证**的清单(不要把下面任何一条当结论) > 📌 **后续(2026-09-30 深夜)**:这张表是**当晚写下时的状态**。其中**已有条目被实测推进**: > - **R-5.3 CSRF** → **host-dev 实测确认「属实」并已修**(`8482fb8` 双栅栏),见 §7.0 的 R-5 行。 > 即:那条从「不知道有没有」变成了「**确实有,已堵**」。 > - **R-4 大库耗时**、**R-7 撤销误清** → 也已在当晚修复/覆盖(见 §7.0)。 > - **R-15(`deferSave` 部分持久化)仍是未验**,且**性质与其他不同**: > 它不是「严重度未知」,而是「**有没有都不知道**」。 | 条目 | 为什么不确定 | |:---|:---| | R-5.3 CSRF / 本机网页能否触发 `/import` | 没构造跨源页面实测;`Content-Type` 不校验是读代码看到的 | | R-7 撤销会误清早先的优先标记 | 需要「批量里混有已标记条目」的场景,没构造 | | R-15 `deferSave` 中断后部分持久化 | 纯代码推理,没触发过中断 | | R-11 单字符词元噪声的实际严重度 | 没做对照用例 | | R-4 大库下语义搜索的真实耗时 | 当前库只有 200+ 条;没造 1 万条的库 | | R-14 `keepId` 被删后整组失去记录 | 需要并发改动场景 | | R-20 `.5px` 在 125%/150% 缩放下的表现 | 没在实际缩放下逐项核对 | | R-5.1 黑名单假阳性的实际发生率 | 取决于用户目录命名,没统计 | ### 7.8 这一节**没有**发现同类问题的类别(查过了) - **导入的正文索引边界**:`≤2MB` + `≤50k 字符` + `NUL` 判二进制 —— **有界,没找到越界路径** - **破坏性动作的二次确认与撤销入口**:整理建议与批量登记**都有**(R-2 是「已有机制报告不准」,不是「没有机制」) - **语义搜索对「记录已被删」的处理**:结构化 404 + 独立文案 + `missed` 计数 —— **完整** - **`unmarked` / `stillNeedsRefine` 的语义诚实度** —— **做得好**,是正面例子 - **容器查询的基准选择**(面板宽度而非视口)—— **正确**