diff --git a/WORKMEMORY/PROJECT_OVERVIEW.md b/WORKMEMORY/PROJECT_OVERVIEW.md index 4cff330..33022f3 100644 --- a/WORKMEMORY/PROJECT_OVERVIEW.md +++ b/WORKMEMORY/PROJECT_OVERVIEW.md @@ -30,13 +30,17 @@ - **未发版(已进 master,2026-10-20):前端单体继续解耦 + 分层闸纳管面扩展**(PR **#161** / **#164** / **#165** / **#166**,计划 `docs/plans/2026-10-19-reader-js-layer-split.md` · `2026-10-19-reader-api-layer-split.md` · `2026-10-20-cards-state-container.md`,设计稿 `docs/specs/2026-10-20-cards-state-view-decoupling-design.md`)。`reader.js` 抽出 `rd-syntax.js`(纯语法层)与 `rd-api.js`(L2 持久化层,14 个端点函数);`cards.js` 的 **15 个模块级可变状态**收进 `cd-state.js` 纯状态容器,视图层顶层 `let` **15 → 0**(首次把视图层纳入机械约束,闸规则 ⑦)。导出面全程冻结(`reader.js` 40 个具名、`cards.js` 36 个 + 1 条 barrel 逐字不变),错误轨守恒(`reader.js` 10/10、`cards.js` 12/12)。 ⚠️ **本批真正的产出不是解耦,是挖出两条与重构无关的既有静默失效**:① `ia_dom_mount_probe.mjs` 有 **4 条守卫一直是死的**(切片锚点只到函数名 ⇒ 切片里没有函数体 ⇒ 断言恒真),② 探针静态层已红时仍照样跑 `node:vm`,空源撞出的 `ReferenceError` 排在真实具名问题**后面**、把诊断引向渲染链(#166 修)。两者形态相同:**判据看着在跑,实际恒真**。纪律已写进 `docs/agents/architecture.md`「前端模块分层契约」节,动探针/守卫前必读。 ⚠️ **计划写法教训(已在计划里更正)**:变异用例的注入锚点必须按**最终状态树**普查 —— Task 1 新造的锚点钉在 `let cardSegment = "due";` 上,而那正是 Task 3 要删的行,于是两条用例挂在「锚点命中 0 次」而不是挂在闸上。另:同一主机上并行子代理曾三次落在**错误血统**的 worktree 上并据此给出与事实相反的汇报,故后续派单一律要求「先 merge 目标分支并核实 HEAD」。 +- **现测基线(2026-10-22 18:30,分支 `feat/desktop-single-instance` @ `58b8f57`;单实例锁交付实测,**当前值**)**:全量 **2586 收集 = 2584 passed + 2 skipped**(单进程串行 **580s**,前置 `PYTHONIOENCODING=utf-8`)· **156** 个 `tests/test_*.py` · `static/js` **36** · `tools/*.mjs` **31** · `ruff check .` 零告警 · `mypy --strict delector tools`(**119** files)/ `mypy --follow-imports=skip tests`(**160** files)/ 入口面 `--platform win32 --strict --follow-imports=skip`(**4** files)零错误 · 六道机械闸全 exit 0 · PR #172 CI 全绿(含 Windows Portable Smoke)。LOC:后端+入口 `*.py` **40,001** / tests **61,312** / `static/js` **16,801**。 + - ⚠️ 入口面那档 MUST 带 `--follow-imports=skip`(漏了会本地假绿,见 `docs/agents/ops.md` 第 13 条第四类)。 +- **历史基线(2026-10-21 23:38,master `85fb029`;已被上一行取代)**:全量 **2519 收集 = 2517 passed + 2 skipped**(`python -m pytest tests/ -q`,单进程串行 **513s**,前置 `PYTHONIOENCODING=utf-8`)· 155 个 `tests/test_*.py` · `static/js` **36** · `tools/*.mjs` **31** · `ruff check .` 零告警 · `mypy --strict delector tools`(**118** files)/ `mypy --follow-imports=skip tests`(**159** files)零错误 · `cd agent && go test ./...`(8 包)全绿 · 六道机械闸全 exit 0。**该基线含 dependabot 的 `fastapi 0.144.0` / `starlette 1.8.0` 升级(#168/#170)⇒ 两个 minor bump 在本机无回归**(此前该升级只报过 CI,未见本地全量结论)。LOC:后端+入口 `*.py` **39,577** / tests **59,429** / `static/js` **16,800**。 - **现测基线(2026-10-21,分支 `fix/master-scope-gate-squash-merge`;本次实测重算)**:全量 155 个文件 · 8 批串行全绿 = 收集 **2519**(**2517 passed + 2 skipped**,净增 6 条 master-scope 健全性与变异自证测试)· `static/js` **36** 个模块 · `tests/test_*.py` **155** 个文件 · `tools/*.mjs` 探针 **31** 个 · `ruff` 零告警 · `mypy --strict delector tools`(**118** files)/ `mypy --follow-imports=skip tests`(**159** files)零错误 · 六道机械闸全 exit 0。 - 重算:`python3 -m pytest tests/ --collect-only -q | tail -1`;`ls static/js/*.js | wc -l`;`ls tests/test_*.py | wc -l`;`ls tools/*.mjs | wc -l`。 - ⚠️ 全量 MUST **分批串行**跑(本主机并行 python 会争抢数据目录守卫导致假红)。 - **现测基线(2026-10-20,master `07139ac`;历史发版前基线)**:全量 155 个文件 · 8 批串行全绿 = 收集 **2513**(**2511 passed + 2 skipped**)· `static/js` **36** 个模块 · `tests/test_*.py` **155** 个文件 · `tools/*.mjs` 探针 **31** 个 · `ruff` 零告警 · `mypy --strict delector tools`(**118** files)/ `mypy --follow-imports=skip tests`(**159** files)零错误 · 六道机械闸全 exit 0。 - **现测基线(2026-10-19,master `4646a67` 发版基线;由本次发版实测)**:收集 **2441** 用例(**2438 passed + 3 skipped**,全量单条串行分批全绿)·`ruff check .` 零告警 · `mypy --strict delector tools`(**118** files)· `mypy --follow-imports=skip tests`(**157** files)· 入口面 `--platform win32 --strict`(**4** files)零错误 · `tools/*.mjs` 探针 **31** 个 · `static/js` **34** 个模块 · `tests/test_*.py` **153** 个文件。**六道机械闸**:`check_layer_invariants` · `check_mock_drift` · `check_silent_degradation` · `check_frontend_api_contract` · `check_frontend_layers` · `check_index_html_sync` 全 exit 0。 - 重算:`python3 -m pytest tests/ --collect-only -q | tail -1`;`python3 -m mypy --strict delector tools | tail -1`;`ls tools/*.mjs | wc -l`;`ls static/js/*.js | wc -l`。 - - ⚠️ **本行已不是当前值**(v5.18.0 发版快照,收集 2441)。当前值看**上面那条「现测基线(2026-10-20)」**。v5.17.0 的 1768 passed 同理是历史快照 —— 读到时 MUST 以 2026-10-20 那条为准,MUST NOT 拿旧值做判断。 + - ⚠️ **本行已不是当前值**(v5.18.0 发版快照,收集 2441)。当前值看**上面那条「现测基线(2026-10-21 23:38,master `85fb029`)」**。v5.17.0 的 1768 passed 同理是历史快照 —— 读到时 MUST 以当前那条为准,MUST NOT 拿旧值做判断。 +- **未发版(分支 `feat/desktop-single-instance`,2026-10-22)**:**桌面单实例锁 + 端口顺延**(计划 `docs/plans/2026-10-21-desktop-single-instance.md`,10 任务)。判据从「端口上有没有东西」换成 OS 文件锁(`%LOCALAPPDATA%\DeLector\instance.lock` + `instance.json` 分离,避免 POSIX `os.replace` 让锁静默失效);唯一判定点 `start.claim_instance`,`start.main()` 与桌面壳共用;端口候选 `8000, 8002–8010`(8001 留给 Go supervisor),自动顺延 / 显式 `--port` 不顺延;锁降级时以 `/api/health` 身份探测作第二腿(否则会为同一个库起第二个服务);`/api/wb/lan-info` 改为报真实端口。桌面壳退出码新增 3 = 端口或实例冲突(六种 `reason` 各有获批的人话文案)。细节见 `docs/agents/architecture.md`「桌面单实例与端口顺延」。 - **未发版(已进 master,2026-10-15)**:**ADR-0023 交互层「验证驱动 agent 循环」Task 0~6**(PR #136,7 个任务单元经 maker-checker 双席位评审)—— Go 门禁三红灯修复并接入 CI(含执行期新发现的「硬编码 8001 且不校验身份 ⇒ 真实风险是假绿」)、LLM 出网收口到 `delector/services/llm_client.py`(5 处裸 HTTP + 补 `max_tokens`)、循环内核 `delector/services/polish_loop.py`(收尾终审 / 不降即停 + argmin 回退 / 循环级 token+deadline 预算 / 零静默失败)、工具白名单 `polish_tools.py`(恰好 3 项只读 + 前置钩子)、`ai-polish` 返回契约新增 `converged`/`unconverged_reason` + SSE 逐轮进度/取消 + 未收敛 toast。**⚠️ 按 ADR-0023 §4.3 不得宣称「已实现」**:裁判集一行未扩,适用性闸门使循环的真实迭代价值目前只覆盖 A1 书信体。剩余活项:① 裁判集扩充(Unknown 1,第一瓶颈)② Android 真机点检(本批动了 `static/`,且 **SSE 的 `fetch`+`ReadableStream` 在 WebView 未验证**)③ 有 Key 后回测真实轮次分布与真 token 计数(折算系数 3.0 是 UNVERIFIED 假设)。 - **未发版(已进 master,2026-10-15 第二批)**:**ADR-0023 §4.3 第 ④ 步「裁判集扩充」**(计划 `docs/plans/2026-10-15-a1-judge-expansion.md`,8 任务全交付,PR **#137**)。裁判从 6 条书信格式/字数浅规则扩到 A1 语法层:**动词位置**(主句 V2 + 8 豁免)· **格的使用**(冠词-名词一致 / 介词支配格 / 动词支配格,29 条人工登记动词→格表)三条 error 级;**时态一致性**接线为 **advisory**(用户拍板);**语序(情态框架)经实测评审判定不做**(FP 与 FN 同源)。同时把**闸门按规则族分流** ⇒ 语法族对**任意德语文本**生效(循环价值面不再只覆盖 A1 书信体),并把提示词按文本族参数化。§4.3 判据表 8 条实测后经用户签批:**采用限定表述,无条件「已实现」继续禁用**(第 8 条对照实验缺 Key 未做)。**排障经验已沉淀**:vault `99-Inbox/2026-10-15-规则引擎硬门必须按「类」覆盖正样本…md`(含"三次误报类全被官方样本集放过"与两条既有规则的新实例)。 ⚠️ 本批最值得复看的产品语义变更:**非书信文本模型修不动语法错 ⇒ `no_improvement` ⇒ 回退原文**(前端给人话原因码;用户签批保持)。 @@ -76,7 +80,7 @@ 3. **README 首屏改造**(宣传线,门槛③剩余部分):截图/GIF + 一句话价值 + 30 秒上手;当前首屏对非技术用户仍是代码仓库脸(badge 墙 + 版本摘要墙)。 4. **预置包 LLM gloss 富化**:阻塞于 `DEEPSEEK_API_KEY` 缺失;`import_encounter_pack` 按 `pack_id` 幂等**不更新**既有行。 5. **C 档 8 项(当前唯一待办入口)**:`docs/reviews/2026-10-16-open-items-backlog.md`。其中 **C1「裁判结论回灌(verifier-in-the-loop)」为无条件下一步**(ADR-0024 四条硬依赖:① ~~裁判 FP 先修完~~ 已确认基线完成 ② 回灌内容 MUST 过同一道适用性闸与双限截断 ③ 定三角一致性(循环裁判带 nlp / `writing_check` **不带** nlp / 回灌源)④ 阴性对照 MUST 在动产品之前先跑成基线)。⚠️ 回灌落地后,§4.3 实验的阈值与结论**只对"准入式裁判"有效** ⇒ 必须重跑或明确分域(ADR-0024 已登记资产折旧,不留给下一任发现)。 -6. **递延项(未立项)**:tests `--strict`;`tools/vault-proactive-scan.py` 三条版本正则无自动化守卫;PWA 安装提示验证 / 192-512 PNG 图标;桌面单实例文件锁(pid+版本+数据目录 + `/api/health` 身份校验 + 顺延 8001–8010);发布链路 runbook(Publish 被跳过 / 孤儿 tag 的恢复路径:Vault v5.4.0 复盘 + **v5.1.1 孤儿 tag 在案**;`gh workflow run --ref ` 理论可补发但**本仓未实证**,判据只能是 release 资产清单)。 +6. **递延项(未立项)**:tests `--strict`;`tools/vault-proactive-scan.py` 三条版本正则无自动化守卫;PWA 安装提示验证 / 192-512 PNG 图标;**桌面单实例与端口顺延已于 2026-10-22 交付**(见「当前状态」,勿再当待办);发布链路 runbook(Publish 被跳过 / 孤儿 tag 的恢复路径:Vault v5.4.0 复盘 + **v5.1.1 孤儿 tag 在案**;`gh workflow run --ref ` 理论可补发但**本仓未实证**,判据只能是 release 资产清单)。 7. **`check_master_commit_scope.py` 把 squash 合并误判成直推【已闭环 (2026-10-21)】**: - 根因:`--no-merges --first-parent` 只能识别多父的 merge-commit 型合并;GitHub squash/rebase 合并产出的单父提交被误划入直推审查对象,导致 #165 / #166 在 PR CI 全绿合并后被 master 直推闸拦截(因整个 PR 改动面不在白名单)。 - 修法(分支 `fix/master-scope-gate-squash-merge`):以 `committer == "GitHub "` 判定 GitHub 集成合并(merge / squash / rebase 三种合并方式均盖该章,本地 git commit 不会生成)。同时封死原有仅凭 `Merge pull request #...` 标题即可绕过检查的单父伪造路径(要求 `is_merge_commit` 即 ≥2 父)。 diff --git a/WORKMEMORY/work.log b/WORKMEMORY/work.log index 31c885a..2d722f4 100644 --- a/WORKMEMORY/work.log +++ b/WORKMEMORY/work.log @@ -652,3 +652,47 @@ Caps: filesystem-read, filesystem-write, shell-exec - `.gitignore` 里 `worktrees` 命中 3 次,逐行核为**仅 1 条真规则**(第 77 行)+ 2 行注释 ⇒ 记忆里那个「每次建工作树都重复追加」的缺陷在本仓当前状态下**未发生**,不动(且 `.gitignore` 不在直推白名单,改它要开 PR)。 - `.worktrees/subagent-sa-0-425e71cd` 经 `ls -a` 判为**无 `.git` 文件的空目录**(未注册垃圾,非他人活工作树 —— 没用 `git -C` 判,它会回落父仓给出假绿)⇒ `rmdir`(只能删空目录,非空即失败=安全)。`.worktrees/` 根目录按仓内约定保留。 - 可恢复性:14 个分支 tip 的对象经 `git cat-file -e` 逐个确认**仍在库中**,`gc` 未 prune ⇒ 90 天内 `git branch ` 可原样复原。 + + +### 2026-10-21 14:46 | hy4-preview__codebuddy | NOTE +Caps: filesystem-read, filesystem-write, shell-exec +事件:**合并两个未合并的 dependabot 依赖更新分支到 master**(用户 2026-10-21 指令)。 +- 分支:`dependabot/pip/starlette-1.8.0`(PR #170,starlette 1.7.0→1.8.0,requirements.txt 行 9)+ `dependabot/pip/fastapi-0.143.3`(PR #168,fastapi 0.143.0→0.144.0,行 8)。两个分支各自仅改 requirements.txt 一行,互不相同行、无功能冲突。 +- 合并方式:本地 `git merge --no-ff`(本仓 `gh pr merge` 缺 workflow scope,不能走)。第二个 merge 因第一行 starlette 已被第一个改动、上下文不匹配 → 冲突,手动解成 `fastapi==0.144.0 / starlette==1.8.0` 两者新值。 +- pre-push 钩子放行:两个 merge commit 改动面仅 requirements.txt ⊆ 白名单。push 后 GitHub 自动把 #168/#170 标 MERGED。 +- 待验证:fastapi 0.144 / starlette 1.8 是 minor bump,项目规矩「先跑全量 pytest(含路由守卫)→ 再改 pin」。已触发 master CI(run 39619531940);升级是否引入回归取决于全量门禁,结果待用户确认。 + +### 2026-10-21 23:45 | deepseek-v4.1-flash__opencode | NOTE +Caps: filesystem-read, filesystem-write, shell-exec +事件:**DFS-ONBOARD 复接手(re-onboard = 验证,不重建架构图)**。分支 `master` @ `85fb029`,开工前后各查一次分支,均为 `master`(无中途换树)。 +- **基线实测(本会话唯一新增真值)**:`PYTHONIOENCODING=utf-8 python -m pytest tests/ -q` ⇒ **2517 passed + 2 skipped in 513.06s**,与 PROJECT_OVERVIEW 的 2519 收集逐字一致。`ruff check .` 零告警;`mypy --strict delector tools` 118 files / `--follow-imports=skip tests` 159 files 均 0 issues;`cd agent && go test ./...` 8 包全绿;六道机械闸(layer_invariants · mock_drift · silent_degradation · frontend_api_contract · frontend_layers · index_html_sync)**全部 exit 0**;`static/js` 36 · `tests/test_*.py` 155 · `tools/*.mjs` 31。LOC:后端+入口 39,577 / tests 59,429 / `static/js` 16,800。 +- **顺手闭合上一条「待验证」**:#168/#170 的 `fastapi 0.144.0` / `starlette 1.8.0`(均为 minor bump)在本机全量 2517 passed 下**无回归**。此前只有 CI 结论、无本地全量证据。 +- **`.agent-context.md` 按 Pre-generation Guard 不重建**:`git log --all -- .agent-context.md` ⇒ 最近一次是 `0709717`「删除过时的 .agent-context.md(与 AGENTS/PROJECT_OVERVIEW 重复且基线停在 559)」——属**有意删除**。等价机器可读锚点 = `WORKMEMORY/PROJECT_OVERVIEW.md`「现测基线」+ `AGENTS.md` 路由表,故本会话只更新前者。 +- **锚点漂移已就地修(3 处,全部为 `architecture.md` 的失效 file 引用,未重画架构)**:① 技术栈表仍写顶层 `routes_a1.py`/`routes_sync.py`/`routes_rtc.py`(v5.18 后实为 `delector/routes/{a1,sync,rtc}.py`)→ 改为真实路径 + `routes/__init__.py:register_routes` 的 include 顺序;② `static/js` 模块数 23 → **36**(实测);③ 信令中继小节 `routes_rtc.py` → `delector/routes/rtc.py`。 +- **三条锚点抽查(`file:line` 仍有效)**:`delector/server.py:352` 静态挂载仍是 `create_app()` 最后一步(红线②成立)· `delector/nlp_engine/syntax_tree.py:1444 split_sentences_pure_python` 仍是唯一实现(红线⑩)· `delector/core/database.py:62 _require_localhost` 仍在(红线⑦)。 +- **新增红线(已写 `docs/agents/ops.md` 工作惯例第 13 条,三类假红)**:① 行尾假阳性——`git ls-files --eol` 实测 **6 个 `i/lf w/mixed`**(3 份 archive work log + `linguistics.py` + `syntax_score.py` 等),blob 干净、差异只在工作区,**MUST NOT** 批量 renormalize/重排;② 终端编码——本机重定向输出是 GBK,非 ASCII 会在产物写出**之后**炸,MUST 先设 `PYTHONIOENCODING=utf-8`,且含非 ASCII 的 JSON 不要用 shell `curl` 验契约;③ exit 0 ≠ 正确——自报口径是 passed 数 / 探针护栏 / 六道闸 / 基线行,且判据本身要有变异自证。 +- 未发现未闭合 `WORK_START`;开放待办原样结转(C 档 8 项,其中 **C1 裁判结论回灌为无条件下一步**;Android 真机点检;README 首屏)。本会话未提交任何改动。 + + +### 2026-10-22 | deepseek-v4.1-flash__opencode | DECISION +Caps: filesystem-read, filesystem-write, shell-exec +事件:**桌面单实例 + 端口顺延** 走完 DFS-SPARK → 用户批准 spec → DFS-PLAN 产物落盘。 +- 产物:`docs/specs/2026-10-21-desktop-single-instance-design.md`(APPROVED,5+2 节,零占位符)+ `docs/plans/2026-10-21-desktop-single-instance.md`(10 任务,TDD,全部带 Subagent Prompt Scaffold / Produces / 退出码与端口哨兵)+ `.vault-exec-ledger.json` 重置为该计划(10 pending)。 +- 决策(与对话初判相反,已在 spec §0 标注):① 锁 = **全局** `%LOCALAPPDATA%\DeLector\`(ADR-0019 §6.4),非 per-data-dir;② 顺延 = **8000, 8002–8010**(8001 是 Go supervisor 保留端口,`agent/internal/pythonsvc/doc.go:9-10`);③ 身份探测降为锁不可用时的第二腿(否则启动期竞争会把「自己正在启动」误判成端口被占,`start.py:203`)。 +- plan 阶段新发现的硬约束(已回写 spec §3.1):**锁与元数据必须分两个文件**。POSIX `flock` 锁 inode 而非路径,若对同一文件 `os.replace`,旧 fd 锁的是已 unlink 的旧 inode、下一个进程可抢到锁 ⇒ 锁静默失效。故 `instance.lock` 只做锁目标、永不替换,`instance.json` 走临时文件+`os.replace`。 +- 顺带纳入的真缺陷:`delector/routes/backup.py:179` 把 `"port": 8000` 写死进 `/api/wb/lan-info`,顺延后手机端会拿到错地址且无提示 ⇒ 改读 `app.state.server_port`(由 `build_server` 写入,默认 8000 保 Android/TestClient 行为)。 +- 守卫影响:`tests/test_desktop_data_dir_bootstrap.py:402` 白名单加 `delector.core.instance_lock`;因该守卫取**全模块最小行号**(`start.py:39` 恒为最小),新增导入不会触发它——加白名单是为意图显式,注释已写清。另补窄守卫证明该模块不 import `database`。 +- 未提交任何文件(用户未要求);分支 `feat/desktop-single-instance` 尚未创建。 + + +### 2026-10-22 | deepseek-v4.1-flash__codebuddy | DECISION +Caps: filesystem-read, filesystem-write, shell-exec +事件:**桌面单实例锁 + 端口顺延**在分支 `feat/desktop-single-instance` 完成 Task 1~9(TDD,逐任务提交)。 +- 交付面:`delector/core/instance_lock.py`(锁记录 + 平台原语 + 三态 `try_acquire`)· `start.claim_instance`(唯一判定点)· `start.main()` 与 `desktop.py` 接线 · `/api/wb/lan-info` 真实端口 · 文档三处。 +- 两处**必须在评审中挡下**的缺陷(都由对抗评审发现,不是测试发现的): + 1. `try_acquire` 原实现把任何 `OSError` 当「他人持有」⇒ NFS/SMB 上 `flock` 返 `ENOLCK` 会被报成「已有实例在跑」且零 WARNING;改为 `_HELD_ERRNOS` 白名单,其余 errno 降级 + 响亮告警。 + 2. `desktop.py` 的「第二腿」接线(`identity_probe=_identity_probe`)曾**无任何用例覆盖**:删掉它 102 条全绿,后果是锁降级机器上会为同一个库起第二个服务。补 main 级用例后,删该 kwarg 即红(实测 stderr 打出「8000 被占用,改用 8002」)。 +- 一条**既有**测试缺陷被暴露:`test_desktop_shell.py` 有 2 条用例靠 patch `start.is_port_in_use` 拦启动,判定点换成锁之后它们的桩失效 ⇒ `start.main()` 真去起服务并**挂死**(pytest 超时 124)。已改到新判定点。 +- 两次重写测试守卫的形状(不是放宽):`_stage_stmt_span` 需穿透 `try` 容器(Task 7 用 try/finally 放锁 ⇒ 锚点下沉一层,且容器自身会因子树包含调用而误命中);`_stage_marker_statements` 同理。两处共用 `tests/server_helpers.py::stmts_through_try`。 +- `check_mock_drift` 新增 3 处豁免:patch `LockPaths.for_platform` 的消费方在 `start.py`(跨模块调用期类属性查找),闸的判据只看目标模块内部引用 ⇒ 属误报;豁免理由逐行可审。 +- 待办:Task 10(全量 pytest + 六道闸 + 真双启动手测 + PR)。 diff --git a/delector/core/instance_lock.py b/delector/core/instance_lock.py new file mode 100644 index 0000000..2af430c --- /dev/null +++ b/delector/core/instance_lock.py @@ -0,0 +1,330 @@ +# -*- coding: utf-8 -*- +r"""桌面单实例锁的记录与元数据读写(spec 2026-10-21 §3.1)。 + +为什么锁与元数据分两个文件 +-------------------------- +POSIX 的 `flock` 锁的是 **inode** 而不是路径。若锁与元数据共用一个文件、每次回写都 +`os.replace`,旧 fd 仍锁着已被 unlink 的旧 inode,下一个进程打开新 inode 就能成功加锁 +—— 锁会**静默失效**。故 `instance.lock` 只做锁目标、内容可为空且永不替换; +`instance.json` 单独走临时文件 + `os.replace`,让读者永远看到完整 JSON。 + +为什么本模块顶层只 import stdlib +------------------------------- +`delector.database` 在导入时就把 `DATA_DIR` 落定;本模块会被 `start.py` 放在 +`bootstrap_data_dir` 之前导入(spec §3.2),一旦间接拉起 `database`,bootstrap +再改 env 也晚了。故本模块的不变式是:**顶层只 import stdlib**(包名可增删,不必枚举)。 +""" + +from __future__ import annotations + +import contextlib +import errno +import importlib +import json +import logging +import os +import sys +import tempfile +from dataclasses import asdict, dataclass +from types import ModuleType +from typing import Mapping, Optional + +APP_DIR_NAME = "DeLector" +LOCK_FILE_NAME = "instance.lock" +META_FILE_NAME = "instance.json" + +#: 具名 logger 沿用仓库惯例(见 `data_dir_bootstrap`),日志总线是 `delector`。 +_LOGGER = logging.getLogger("delector") + +#: 平台原语导入失败只在**首次**降级时喊一次;否则每次启动都刷一条同样的 WARNING 会淹掉日志。 +_PRIMITIVE_MISSING_WARNED = False + +#: 这些 errno 才代表「别人已经持有锁」。其余 errno(`ENOLCK`/`EIO`/`ENOSPC`…)是原语或文件系统 +#: 本身出问题,必须走降级(`None`)并留痕 —— 一律当成「被持有」会把 NFS/SMB 上的失败伪装成 +#: 「已有实例在跑」,用户永远看不到 WARNING。Windows `msvcrt.locking` 的冲突码也是 `EACCES`。 +_HELD_ERRNOS = frozenset({errno.EACCES, errno.EAGAIN, errno.EWOULDBLOCK}) + + +@dataclass(frozen=True) +class LockRecord: + pid: int + version: str + port: int + data_dir: str + acquired_at: str + + +#: 读不出对方元数据时的占位记录:明确「有实例在跑,但身份未知」,绝不猜、绝不复用。 +UNKNOWN_RECORD = LockRecord(pid=0, version="unknown", port=0, data_dir="", acquired_at="") + + +@dataclass(frozen=True) +class LockPaths: + lock: str + meta: str + + @staticmethod + def for_platform(env: Mapping[str, str]) -> "LockPaths": + """平台默认落点。**刻意不建目录**:建目录是调用方职责,纯函数才便于注入测试。""" + local = env.get("LOCALAPPDATA") + if local: + # 显式 env 优先于 os.name:测试用 env 模拟另一平台,不能被跑测主机覆盖。 + base = os.path.join(local, APP_DIR_NAME) + else: + # env 里显式出现 XDG/HOME 说明调用方在模拟 POSIX(主机可能是 Windows), + # 故 env 优先于 os.name;两边都没有才退回本机平台默认。 + xdg = env.get("XDG_DATA_HOME") + home = env.get("HOME") + if xdg: + base = os.path.join(xdg, APP_DIR_NAME) + elif home: + base = os.path.join(home, ".local", "share", APP_DIR_NAME) + elif os.name == "nt": + base = os.path.join(os.path.expanduser("~/AppData/Local"), APP_DIR_NAME) + else: + base = os.path.join(os.path.expanduser("~"), ".local", "share", APP_DIR_NAME) + return LockPaths( + lock=os.path.join(base, LOCK_FILE_NAME), + meta=os.path.join(base, META_FILE_NAME), + ) + + +@dataclass +class LockHandle: + """已持有的锁句柄。 + + 为什么 `_fd` 必须全程保持打开 + ---------------------------- + 锁的拥有者是这个 fd(Windows 句柄 / POSIX open file description),不是 record。 + 一旦关闭,OS 立刻释放锁 —— 崩溃也同理(spec §4「进程被杀 ⇒ 锁自愈」), + 所以这里**不做 pid 探活**:pid 复用与「刚死未回收」都会骗人。 + """ + + paths: LockPaths + record: LockRecord + _fd: int = -1 + + @property + def is_degraded(self) -> bool: + """本次没有真互斥(原语缺失 / 目录不可建 / 文件打不开):仍拿到句柄,但不是锁。""" + return self._fd < 0 + + def release(self) -> None: + """幂等释放:关闭 fd(OS 随之解锁)。降级句柄(`_fd == -1`)为空操作。""" + fd = self._fd + # 先置 -1 再关闭:即使 os.close 抛异常,第二次 release 也不会对同一 fd 重复操作。 + self._fd = -1 + if fd < 0: + return + _close_fd(fd) + + +def read_record(paths: LockPaths) -> Optional[LockRecord]: + """读元数据;缺失 / 非 JSON / 字段缺失或类型不对 ⇒ None(不把解析异常抛给启动主链)。""" + try: + # utf-8-sig:Windows 记事本等会写 BOM,带 BOM 的 JSON 会被 json 判成非法。 + with open(paths.meta, "r", encoding="utf-8-sig") as handle: + data = json.load(handle) + except (OSError, ValueError): + # JSONDecodeError 是 ValueError 子类;「文件不存在」与「半截 JSON」都收敛为版本未知。 + return None + if not isinstance(data, dict): + return None + pid = data.get("pid") + version = data.get("version") + port = data.get("port") + data_dir = data.get("data_dir") + acquired_at = data.get("acquired_at") + # bool 是 int 的子类:`{"pid": true}` 不能被当成合法 pid。 + if not isinstance(pid, int) or isinstance(pid, bool): + return None + if not isinstance(port, int) or isinstance(port, bool): + return None + if not isinstance(version, str) or not isinstance(data_dir, str) or not isinstance(acquired_at, str): + return None + return LockRecord(pid=pid, version=version, port=port, data_dir=data_dir, acquired_at=acquired_at) + + +def write_record(handle: LockHandle, record: LockRecord) -> None: + """原子回写元数据:临时文件写满 + `os.replace`,读者要么看到旧 JSON、要么看到新 JSON。""" + paths = handle.paths + tmp_path = "" + try: + with tempfile.NamedTemporaryFile( + "w", + dir=os.path.dirname(paths.meta), + suffix=".tmp", + delete=False, + encoding="utf-8", + ) as tmp: + tmp_path = tmp.name + json.dump(asdict(record), tmp, ensure_ascii=False) + tmp.flush() + os.fsync(tmp.fileno()) + os.replace(tmp_path, paths.meta) + tmp_path = "" + finally: + # 任何失败类型(含 TypeError)都要清掉半截临时文件;清理失败不得掩盖原始异常。 + if tmp_path: + with contextlib.suppress(OSError): + os.unlink(tmp_path) + + +# ── 平台锁原语 ──────────────────────────────────────────────────────────────── +# +# 为什么用 importlib 惰性导入而不是模块级 `if sys.platform: import msvcrt/fcntl` +# ------------------------------------------------------------------------- +# 同一份源码要在两个平台上都通过 `mypy --strict`:Windows 的 typeshed 把 `fcntl` 声明成 +# 空模块(无 flock / LOCK_EX),模块级导入会在本机报 attr-defined。惰性导入同时把 +# 「原语不可用」变成可返回值(None),让降级显式、可测,而不是在导入期炸掉启动主链。 +# +# 先例:本机另一代码库的 acquire_lock 是同一套三档结构 —— 非阻塞试锁 → 失败即读对方身份 +# → 原语不可用则响亮降级。这里只借结构,实现全部基于 stdlib。 + + +def _load_primitive(name: str) -> Optional[ModuleType]: + try: + return importlib.import_module(name) + except ImportError: + return None + + +def _warn_degraded(reason: str) -> None: + """锁降级的唯一喇叭。文案必须点明「不是真互斥」,否则读日志的人会以为单实例仍成立。""" + _LOGGER.warning("单实例锁降级(%s):本次不是真互斥,同机多实例可能同时启动。", reason) + + +def _warn_missing_primitive_once() -> None: + global _PRIMITIVE_MISSING_WARNED + if _PRIMITIVE_MISSING_WARNED: + return + _PRIMITIVE_MISSING_WARNED = True + _warn_degraded(f"平台 {sys.platform!r} 无可用的文件锁原语") + + +def _close_fd(fd: int) -> None: + """关 fd 即解锁。关闭失败必须留痕:静默吞掉会让人误以为锁已释放。""" + try: + os.close(fd) + except OSError as exc: + _LOGGER.warning("关闭单实例锁 fd 失败(fd=%d):%s", fd, exc) + + +def _acquire_windows_lock(fd: int) -> Optional[bool]: + # 「原语缺失」的三个来源(模块、加锁函数、常量)统一在这里喊话:`try_acquire` 看到 `None` + # 时无法区分「原语缺失」与「原语存在但加锁被拒」,兜底喊话会给出假陈述。 + msvcrt = _load_primitive("msvcrt") + if msvcrt is None: + _warn_missing_primitive_once() + return None + locking = getattr(msvcrt, "locking", None) + # 常量同理属于原语:无默认值的 getattr 会让 AttributeError 穿出 try_acquire 并泄漏锁 fd。 + lock_nblck = getattr(msvcrt, "LK_NBLCK", None) + if locking is None or lock_nblck is None: + # 名字在模块里不存在属于_attribute_缺失而不是 OSError:必须降级,不能让它穿出去。 + _warn_missing_primitive_once() + return None + # 锁区间从文件头算起:不 seek 回去就会锁在 fd 上一次读写留下的位置。 + os.lseek(fd, 0, os.SEEK_SET) + try: + locking(fd, lock_nblck, 1) + except OSError as exc: + if exc.errno in _HELD_ERRNOS: + # 非阻塞加锁的冲突码 ⇒ 别人持有,这是判定结果而不是错误。 + return False + _warn_degraded(f"Windows 加锁失败(errno={exc.errno}):{exc}") + return None + return True + + +def _acquire_posix_lock(fd: int) -> Optional[bool]: + # 同 Windows:模块 / 加锁函数 / 常量三种缺失都在这里喊话,`None` 只剩「原语不可用」一义。 + fcntl = _load_primitive("fcntl") + if fcntl is None: + _warn_missing_primitive_once() + return None + flock = getattr(fcntl, "flock", None) + lock_ex = getattr(fcntl, "LOCK_EX", None) + lock_nb = getattr(fcntl, "LOCK_NB", None) + if flock is None or lock_ex is None or lock_nb is None: + _warn_missing_primitive_once() + return None + try: + flock(fd, lock_ex | lock_nb) + except OSError as exc: + if exc.errno in _HELD_ERRNOS: + return False + # 例:NFS/SMB 上 flock 返 ENOLCK —— 这是原语不可用,不是「已有实例在跑」。 + _warn_degraded(f"POSIX 加锁失败(errno={exc.errno}):{exc}") + return None + return True + + +def _acquire_os_lock(fd: int) -> Optional[bool]: + """True=拿到;False=被他人持有;None=本平台原语缺失(调用方降级,不阻断启动)。""" + if sys.platform == "win32": + return _acquire_windows_lock(fd) + return _acquire_posix_lock(fd) + + +def _ensure_parent_dir(paths: LockPaths) -> bool: + """`os.open` 打开的是 `paths.lock`、回写落在 `paths.meta`:两个父目录都要建。 + + 只建 meta 的父目录时,任意「lock 与 meta 不同目录」的 `LockPaths` 组合都会在 `os.open` 处 + 降级;反过来只建 lock 的父目录则回写必失败。 + """ + parents = dict.fromkeys((os.path.dirname(paths.lock), os.path.dirname(paths.meta))) + for parent in parents: + if not parent: + continue + try: + os.makedirs(parent, exist_ok=True) + except OSError as exc: + _warn_degraded(f"无法创建锁目录 {parent}({exc})") + return False + return True + + +def try_acquire(paths: LockPaths, record: LockRecord) -> "LockHandle | LockRecord": + """非阻塞取锁:拿到 ⇒ `LockHandle`;被持有 ⇒ 对方的 `LockRecord`(读不出 ⇒ `UNKNOWN_RECORD`)。 + + 为什么任何失败都返回句柄而不是抛 + ------------------------------ + 锁是「防止两个实例同时改库」的加固层,不是启动的前置条件。目录不可建 / 文件打不开 + (只读介质、权限)时把用户挡在门外比降级更糟(spec §2.2 最后一行),故一律降级为 + 无互斥句柄并 WARNING;调用方拿到 `LockHandle` 就继续启动,行为等于今天的端口探测路径。 + """ + if not _ensure_parent_dir(paths): + return LockHandle(paths=paths, record=record, _fd=-1) + + try: + fd = os.open(paths.lock, os.O_RDWR | os.O_CREAT, 0o600) + except OSError as exc: + _warn_degraded(f"无法打开锁文件 {paths.lock}({exc})") + return LockHandle(paths=paths, record=record, _fd=-1) + + acquired = _acquire_os_lock(fd) + if acquired is None: + # `None` 有两种来源:原语缺失(已在 `_acquire_*_lock` 里喊过)、原语存在但加锁被拒 + # (那里已打含真实 errno 的 WARNING)。此处再喊会给出假陈述,还会把 + # `_PRIMITIVE_MISSING_WARNED` 置真,让同进程后续真·原语缺失变成零 warning。 + _close_fd(fd) + return LockHandle(paths=paths, record=record, _fd=-1) + if not acquired: + _close_fd(fd) + # 锁被持有但 meta 缺失/半截是合法中间态:收敛为 unknown,绝不把 None 漏给调用方。 + return read_record(paths) or UNKNOWN_RECORD + + handle = LockHandle(paths=paths, record=record, _fd=fd) + try: + write_record(handle, record) + except OSError as exc: + # 锁已经拿到了:回写失败(Windows 上并发 read_record 会让 os.replace 抛 WinError 32) + # 只影响「别人看到谁」,不影响互斥,故照样返回句柄继续启动,绝不把 fd 悬空抛出去。 + _warn_degraded(f"回写元数据失败({exc}):锁仍持有,对方身份可能读到旧值") + except (TypeError, ValueError) as exc: + # 记录本身不可序列化 = 调用方 bug:不吞(warn 显式点名),也不留悬空 fd。 + handle.release() + _warn_degraded(f"锁记录无法序列化({exc!r}):已释放锁,本次不是真互斥") + return LockHandle(paths=paths, record=record, _fd=-1) + return handle diff --git a/delector/core/server_lifecycle.py b/delector/core/server_lifecycle.py index ad88bc8..9a8a80c 100644 --- a/delector/core/server_lifecycle.py +++ b/delector/core/server_lifecycle.py @@ -68,6 +68,14 @@ def build_server(host: str, port: int, app: Any) -> uvicorn.Server: log_level="info", timeout_graceful_shutdown=SHUTDOWN_GRACE_S, ) + # 把**真实**监听端口挂到 `app.state`:`GET /api/wb/lan-info` 读它拼给手机端。 + # 此前那里写死 8000 —— 端口顺延后手机拿到的是一个错地址且没有任何提示。 + # 用 `getattr` 保护而不是直接 `app.state`:`app` 也可能是测试替身或非 FastAPI 的 ASGI + # 应用,那种场景下记录端口只是可选能力,绝不能因此让建服失败(**不静默**:没记录时 + # lan-info 会退回默认 8000,行为与本改动前一致)。 + state = getattr(app, "state", None) + if state is not None: + setattr(state, "server_port", port) return uvicorn.Server(config) diff --git a/delector/routes/backup.py b/delector/routes/backup.py index 92563ed..cbee68e 100644 --- a/delector/routes/backup.py +++ b/delector/routes/backup.py @@ -155,9 +155,14 @@ def wb_regenerate_state_key(request: Request) -> Dict[str, Any]: @router.get("/api/wb/lan-info") -def wb_lan_info() -> Dict[str, Any]: +def wb_lan_info(request: Request) -> Dict[str, Any]: """局域网可读主机信息:配对 UI 提示「把 IP 填进手机」用。不含任何机密。 + `port` 取自 `app.state.server_port`(`build_server` 写入的真实监听端口)—— 此前写死 + 8000,端口顺延后手机会拿到一个连不上的地址。**刻意不用** `request.url.port`:那个值 + 来自 `Host` 头,可被客户端伪造,会把「手机该填什么」交到请求方手里。默认 8000 保 + TestClient / Android 的既有行为不变。 + lan_ip 是尽力而为的探测(本机多网卡时取第一个私有 IPv4),探测不到返回空串, UI 应退化为手填地址。instance_id 与 /api/wb/sync/info 同源,用于识别是否同一实例。 """ @@ -174,9 +179,10 @@ def wb_lan_info() -> Dict[str, Any]: break except Exception: lan_ip = "" + state = getattr(getattr(request, "app", None), "state", None) return { "hostname": socket.gethostname(), - "port": 8000, + "port": getattr(state, "server_port", 8000), "lan_ip": lan_ip, "instance_id": _SYNC_INSTANCE_ID, } diff --git a/desktop.py b/desktop.py index 9f5608d..b150824 100644 --- a/desktop.py +++ b/desktop.py @@ -66,8 +66,9 @@ # 一次模型加载(有机器依赖,慢盘上可达数秒)⇒ 取 60s 兜底,宁慢勿"静默判未就绪"。 READY_DEADLINE_S = 60.0 -# 默认端口:桌面窗口路径用它;`--server-only` 复用 `start.main()` 时**固定**用它 -# —— 该路径不接受 `--port`(见 dispatch 的显式警告),此常量是其真实端口的单一真相。 +# 历史默认端口:仍是自动模式的**首选**端口(候选表首项,见 `start.PORT_CANDIDATES`)。 +# `--port` 的默认值自 Task 7 起是 `None`(自动),本常量只用于「用户显式给的就是这个」的判定 +# 与既有地址文件/测试的锚点 —— 端口真相的单一来源已是 `start.claim_instance`。 DEFAULT_PORT = 8000 # ── AppUserModelID(Win32 任务栏身份)──────────────────────────────────────── @@ -870,97 +871,178 @@ def _show_window(server_handoff: Any, port: int, log_path: str) -> int: return result[0] -def _check_port_or_fail(port: int, log_path: str) -> bool: - """桌面建服**之前**的端口/身份校验(P0-1:防旧实例冒充新构建)。 +def _identity_probe(port: int) -> bool: + """第二腿:锁**降级**时回答「这个端口上是不是 DeLector」(`claim_instance` 的入参)。 - 缺陷背景:此前直接 `build_server` + `serve_in_thread`,没有任何校验。uvicorn - 绑定失败走 `sys.exit` ⇒ 被 `threading.excepthook` 静默忽略 ⇒ server 线程真死; - 而 `await_ready` 对**旧实例**照样返回 200+身份 ⇒ 照常亮主窗口 ⇒ 用户以为升级了, - 实际是旧进程在服务。三分支: - - 端口空闲 ⇒ True,正常继续; - - 被占且身份是 DeLector ⇒ 弹窗 + 日志 + False(拒绝启动)。**刻意不做**"连过去" - 的窗口复用——那会把复杂度带回 P0 时序雷区,拒绝启动是诚实且最简单的处理; - - 被占但不是 DeLector ⇒ 弹窗 + 日志 + False。 - 所有失败路径必须**弹窗 + 日志**(调用方据此非零退出),不许只写日志。 + 惰性 import 源模块属性而非把函数对象绑到本模块:测试要能 monkeypatch + `delector.core.server_lifecycle.probe_identity` 就生效(绑死会在 import 期焊住旧对象)。 + 锁是唯一判据,只有没抢到真锁(只读目录 / 无锁原语)时本函数才会被调到。 """ - # 惰性 import:复用 start.py 的既有判定与 server_lifecycle 的既有身份探测, - # 不另写一套(两处判法一旦漂移,桌面端与 `--server-only` 就会对同一端口给出不同结论)。 - import start from delector.core.server_lifecycle import probe_identity - if not start.is_port_in_use(port): - return True - if probe_identity(port): - _append_log( - log_path, f"端口 {port} 已有 DeLector 在运行:拒绝启动(防旧实例冒充新构建)。" + # 显式落到 bool 局部变量:入口面 mypy 闸带 `--follow-imports=skip`(见 `.github/workflows/ci.yml`) + # ⇒ 被跳过的模块符号是 `Any`,直接 return 会报 `no-any-return`(该红只在 CI 出现)。 + identity: bool = probe_identity(port) + return identity + + +def _describe_holder( + record: Any, reason: str, log_path: str, requested_port: Optional[int] +) -> Tuple[str, str]: + """把 `AlreadyRunning` 翻成(弹窗标题, 正文)—— 弹窗与 launch.log 共用同一句话。 + + 文案约束(ADR-0020 §4 + 本计划的全局约束):只给人话(版本 / pid / 端口 / 数据目录 / + 日志路径),**禁含** traceback 与内部标识符;`record.port <= 0` 时**不得**写出"端口 0" + —— 那是"已抢锁、端口尚未回写"的哨兵值,不是可访问的地址。 + """ + # 惰性 import:桌面壳的模块顶层已在 bootstrap 语义之外,但保持与 start.py 同款取法, + # 避免版本号在 import 期被焊成常量(发版脚本可能改写它)。 + from delector.core.version import APP_VERSION + + if reason == "port_busy": + # `port_busy` 的 record 是 UNKNOWN_RECORD(port=0)⇒ 真实端口只能来自调用方。 + busy = requested_port if requested_port else record.port + return ( + "端口被占用", + f"端口 {busy} 被其他程序占用,DeLector 无法启动。" + f"请关闭占用该端口的程序后重试。详情见 {log_path}", ) - _message_box( + if reason == "no_free_port": + return ( + "端口被占用", + "端口 8000–8010 全部被其他程序占用,DeLector 无法启动。" + f"请关闭占用端口的程序后重试。详情见 {log_path}", + ) + if reason == "version_mismatch": + return ( + "DeLector 版本冲突", + f"这台上已有一个 DeLector 在运行,但版本不同(正在运行 v{record.version}," + f"本程序 v{APP_VERSION})。请先退出旧版本再启动。详情见 {log_path}", + ) + if reason == "data_dir_mismatch": + return ( + "数据目录冲突", + f"已有一个 DeLector 在运行,但它使用的是另一份数据目录({record.data_dir})。" + f"两者同时运行会让学习进度分裂在两份库里。请先退出它。详情见 {log_path}", + ) + if reason == "same" and record.port > 0: + # 只列**已知**字段:锁降级时身份探测只能确认"端口上是 DeLector",读不到 meta ⇒ + # version="unknown"、pid=0。照抄进弹窗就是"vunknown,进程 0"——比不说更糟。 + known: List[str] = [] + if record.version and record.version != "unknown": + known.append(f"v{record.version}") + if record.pid > 0: + known.append(f"进程 {record.pid}") + known.append(f"端口 {record.port}") + return ( "DeLector 已在运行", - f"已有 DeLector 在运行(端口 {port})。若要使用新版本,请先退出旧实例。", + f"已有 DeLector 在运行({','.join(known)})。" + f"若要使用它,请打开 http://127.0.0.1:{record.port}。详情见 {log_path}", ) - return False - _append_log(log_path, f"端口 {port} 被其他程序占用:拒绝启动。") - _message_box( - "端口被占用", - f"端口 {port} 被其他程序占用,DeLector 无法启动。请关闭占用该端口的程序后重试。", + # 兜底:`unknown`,或 `same` 但端口未知。不猜版本也不猜端口 —— 那两个字段此刻是占位值。 + return ( + "DeLector 已在运行", + "已有一个 DeLector 在运行,但读不到它的版本与端口。若刚刚双击过图标,请稍等几秒再试;" + f"若持续如此,请先结束残留进程。详情见 {log_path}", ) - return False -def _run_desktop_inner(port: int, log_path: str) -> int: - """桌面壳主体:端口/身份校验 → 检测运行时 → 定数据落点 → 起窗口;**任何**路径都先收尸。 +def _check_port_or_fail(port: Optional[int], log_path: str, claim_out: Dict[str, Any]) -> bool: + """桌面建服**之前**的单实例判定(P0-1 的落点;判定本体收敛在 `start.claim_instance`)。 + + 返回 True ⇒ 已拿到锁、端口已选定;`claim_out["port"]` / `claim_out["lock"]` 已填好, + 调用方 MUST 在用完后 `lock.release()`。返回 False ⇒ 已有实例 / 端口不可用:**已弹窗 + + 已落 launch.log**,调用方应返回 3。 + + 为什么用出参 dict 而不是返回元组:`server_handoff` 已用同一范式;且本函数的调用形状 + `if not _check_port_or_fail(...)` 被 `tests/test_desktop_window_lifecycle.py` 的阶段标记 + 夹逼守卫钉住(改成赋值语句会让那条守卫找不到锚点)。 + + 为什么不再自己探端口/身份:判据必须与 `--server-only`、脚本入口**同一份**。两处各写一套 + 就会漂成"壳说没有、服务说有两个"——那种缺陷不报错,只让两个窗口抢同一个库。 + """ + # 惰性 import start:复用同一份判定,并顺带拿到它在启动期才需要的模块(与本文件既有惯例一致)。 + import start + + decision = start.claim_instance(os.environ, requested_port=port, identity_probe=_identity_probe) + if isinstance(decision, start.AlreadyRunning): + title, detail = _describe_holder(decision.record, decision.reason, log_path, port) + _append_log(log_path, f"拒绝启动({decision.reason}):{detail}") + _message_box(title, detail) + return False + claim_out["port"] = decision.port + claim_out["lock"] = decision.lock + return True + + +def _run_desktop_inner(port: Optional[int], log_path: str) -> int: + """桌面壳主体:单实例判定 → 检测运行时 → 定数据落点 → 起窗口;**任何**路径都先收尸。 Task 3:建服/起服(`from delector.server import app` / `build_server` / `serve_in_thread`) 已搬进 `_after_start`(`webview.start()` 调度的事件循环内)——重活推迟到 splash 可见之后, - 消灭 4~6.5s 零反馈。此处只留循环外的快速校验与落点;服务由 `_after_start` 落进 + 消灭 4~6.5s 零反馈。此处只留循环外的快速判定与落点;服务由 `_after_start` 落进 `server_handoff`,在本函数 `finally` 里读出并 `coordinated_shutdown` 收尸。 + + Task 7:建服前的判定换成 `start.claim_instance`(锁是唯一判据),端口也由它选定 —— + `port is None` = 自动(8000 起顺延到 8010),整数 = 用户显式指定(**不**顺延)。 + 拿到的那把锁 MUST 在**所有**返回路径上释放:否则同进程重试(测试、壳的"失败后重开") + 会撞上自己的锁,被判成"已有实例在运行"——一条指向错误方向的假红。 """ - # P0-1:建服**最早处**先校验端口/身份(要早、要响、要简单)。 - # 校验失败 ⇒ 已弹窗 + 已落日志 ⇒ 退出码 3(2 已被"缺 WebView2"占用)。 - if not _check_port_or_fail(port, log_path): + # P0-1:建服**最早处**先做单实例判定(要早、要响、要简单)。 + # 冲突 ⇒ 已弹窗 + 已落日志 ⇒ 退出码 3(2 已被"缺 WebView2"占用)。 + claim: Dict[str, Any] = {} + if not _check_port_or_fail(port, log_path, claim): return 3 _append_log(log_path, "端口与身份校验通过。") - if not ensure_webview2_runtime(): - _append_log(log_path, "未检测到 WebView2 运行时:提示安装并退出。") - _message_box("缺少 WebView2 运行时", webview2_missing_message()) - return 2 - _append_log(log_path, "WebView2 运行时检测通过。") - - # ⚠️ 必须在 `from delector.server import app` **之前**:那一行一跑,`database` 已按仓库根把 - # DATA_DIR 落定,之后再设 DELECTOR_DATA_DIR 就晚了(与 start.py 同款接线)。 - # (Task 3:那行 import 连同 build_server / serve_in_thread 已搬进 `_after_start`——重活推迟 - # 到 splash 可见之后;但顺序约束不变:此处先落定 DATA_DIR,`_after_start` 才 import server。) - bootstrap_data_dir(os.environ) - _append_log(log_path, "数据目录落点已定(bootstrap_data_dir 已返回,不代表迁移已落稳)。") - # 桌面态 LAN 可用时同样落一份地址(托盘「显示局域网地址」读它;--windowed 无控制台)。 - # 放在 bootstrap 之后:env 里的数据目录落点已定,文件与库、launch.log 同处一地。 - write_lan_address(port, os.environ, log_path) - - # 服务交接容器:`_after_start`(webview.start 调度的事件循环内)建好服务后把 server/thread - # 写进来;`webview.start()` 返回(窗口全关)后由此处读出并收尸。空 dict 由本函数创建、经 - # `_show_window` 透传——`_after_start` 跑在后台线程,无法直接 return 给主线程。 - server_handoff: Dict[str, Any] = {} + resolved_port: int = claim["port"] + lock: Any = claim["lock"] try: - return _show_window(server_handoff, port, log_path) + if not ensure_webview2_runtime(): + _append_log(log_path, "未检测到 WebView2 运行时:提示安装并退出。") + _message_box("缺少 WebView2 运行时", webview2_missing_message()) + return 2 + _append_log(log_path, "WebView2 运行时检测通过。") + + # ⚠️ 必须在 `from delector.server import app` **之前**:那一行一跑,`database` 已按仓库根把 + # DATA_DIR 落定,之后再设 DELECTOR_DATA_DIR 就晚了(与 start.py 同款接线)。 + # (Task 3:那行 import 连同 build_server / serve_in_thread 已搬进 `_after_start`——重活推迟 + # 到 splash 可见之后;但顺序约束不变:此处先落定 DATA_DIR,`_after_start` 才 import server。) + bootstrap_data_dir(os.environ) + _append_log(log_path, "数据目录落点已定(bootstrap_data_dir 已返回,不代表迁移已落稳)。") + # 桌面态 LAN 可用时同样落一份地址(托盘「显示局域网地址」读它;--windowed 无控制台)。 + # 放在 bootstrap 之后:env 里的数据目录落点已定,文件与库、launch.log 同处一地。 + # MUST 用**判定后的**端口:顺延时把 8000 写进地址文件等于给手机一个连不上的地址。 + write_lan_address(resolved_port, os.environ, log_path) + + # 服务交接容器:`_after_start`(webview.start 调度的事件循环内)建好服务后把 server/thread + # 写进来;`webview.start()` 返回(窗口全关)后由此处读出并收尸。空 dict 由本函数创建、经 + # `_show_window` 透传——`_after_start` 跑在后台线程,无法直接 return 给主线程。 + server_handoff: Dict[str, Any] = {} + try: + return _show_window(server_handoff, resolved_port, log_path) + finally: + # 关窗 / 异常 / Ctrl+C 全部经此收尸(与托盘「退出」同一路径),绝不留残留服务。 + # server/thread 由 `_after_start` 落进 handoff;建服前即失败/退出的极端边界下可能缺失, + # 绝不让 None 冲进 coordinated_shutdown(那会在 shutdown 里 AttributeError)。 + server = server_handoff.get("server") + thread = server_handoff.get("thread") + if server is None or thread is None: + _append_log(log_path, "无已启动服务可收尸(建服阶段之前即失败或退出)。") + else: + coordinated_shutdown(server, thread, log_path) finally: - # 关窗 / 异常 / Ctrl+C 全部经此收尸(与托盘「退出」同一路径),绝不留残留服务。 - # server/thread 由 `_after_start` 落进 handoff;建服前即失败/退出的极端边界下可能缺失, - # 绝不让 None 冲进 coordinated_shutdown(那会在 shutdown 里 AttributeError)。 - server = server_handoff.get("server") - thread = server_handoff.get("thread") - if server is None or thread is None: - _append_log(log_path, "无已启动服务可收尸(建服阶段之前即失败或退出)。") - else: - coordinated_shutdown(server, thread, log_path) + # 缺 WebView2(返回 2)/ 正常关闭(返回 0)/ 异常:三条路径都放锁。 + # 冲突路径在上方早早 return,那时还没拿到锁,不在此处。 + lock.release() -def run_desktop(port: int = DEFAULT_PORT, debug_console: bool = False) -> int: - """启动桌面壳:返回**进程退出码**。 +def run_desktop(port: Optional[int] = None, debug_console: bool = False) -> int: + """启动桌面壳:返回**进程退出码**。`port=None` = 自动(8000 起顺延),整数 = 显式指定。 **所有**退出路径(关窗 / 托盘退出 / 异常 / Ctrl+C)都会先 `shutdown(server, thread)`: - 干净退出返回 0;就绪超时返回 1;缺 WebView2 返回 2;端口被占(DeLector 或其他程序) - 返回 3(建服前的端口/身份校验,见 `_check_port_or_fail`);收尸超时则经 + 干净退出返回 0;就绪超时返回 1;缺 WebView2 返回 2;端口或实例冲突返回 3 + (建服前的单实例判定,见 `_check_port_or_fail`);收尸超时则经 `coordinated_shutdown` 硬退出(`os._exit(1)`),不留残留进程。 """ log_path = resolve_log_path(os.environ) @@ -995,7 +1077,14 @@ def _parse_args(argv: Optional[List[str]]) -> argparse.Namespace: prog="desktop.py", description="DeLector Windows 桌面壳(pywebview 窗口 + pystray 托盘,ADR-0020)。", ) - parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"本地服务端口(默认 {DEFAULT_PORT})") + # 默认 None = 自动:8000 被占就顺延到 8002–8010(8001 是 supervisor 保留端口)。 + # 显式给定时**不**顺延 —— 调用方(快捷方式 / 反代 / hosts)已经对外承诺了那个地址。 + parser.add_argument( + "--port", + type=int, + default=None, + help="本地服务端口(默认自动:8000,被占则顺延到 8002–8010;显式指定时不顺延)", + ) parser.add_argument( "--server-only", action="store_true", @@ -1046,13 +1135,13 @@ def _on_serving(port: int) -> None: return 0 -def dispatch(server_only: bool, port: int, debug_console: bool = False) -> int: +def dispatch(server_only: bool, port: Optional[int], debug_console: bool = False) -> int: """入口分派(可测的纯接线):`--server-only` ⇒ 既有服务;否则 ⇒ 桌面窗口。 - ⚠️ `--server-only` **复用** `start.main()`(固定端口,见 `run_server_only` 的理由):该路径 - 不接受 `--port`。用户若在此组合下显式给了非默认端口,必须**显式告知**它不生效 —— 静默丢弃会被 - 误读成"端口没换成功",正是本任务要消除的一类静默失败(只给 `start.main()` 加一个**可选回调** - 参数、不加 `port` 参数,故不引入 port-identity / LAN 面回归)。 + ⚠️ `--server-only` **复用** `start.main()`(其端口由 `claim_instance` 决定),本入口不把 + `--port` 传下去。用户若在此组合下显式给了端口,必须**显式告知**它不生效 —— 静默丢弃会被 + 误读成"端口没换成功",正是本任务要消除的一类静默失败。 + `port is None`(默认自动)不算显式指定,不该为它报警。 """ if not server_only: if debug_console: @@ -1064,9 +1153,9 @@ def dispatch(server_only: bool, port: int, debug_console: bool = False) -> int: rotate_log_if_needed(log_path) ensure_streams(log_path, debug_console) # 守卫式压平(最大缩进 2 层):下面这条警告是"不静默"的落点,理由见上方 docstring。 - if port != DEFAULT_PORT: + if port is not None and port != DEFAULT_PORT: print( - f"[警告] --server-only 复用既有服务,固定端口 {DEFAULT_PORT}:" + f"[警告] --server-only 复用既有服务,端口由服务自行选定(8000 起顺延):" f"你传入的 --port {port} 不会生效。", file=sys.stderr, ) diff --git a/docs/agents/architecture.md b/docs/agents/architecture.md index ca26856..85cf607 100644 --- a/docs/agents/architecture.md +++ b/docs/agents/architecture.md @@ -9,14 +9,14 @@ | 层 | 技术 | 关键文件 | | ---------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 后端 | Python 3.10+, FastAPI, spaCy, genanki | `server.py`(挂载路由与静态资源), `routes_a1.py`(A1 考纲路由), `routes_sync.py`(WebRTC 同步路由), `routes_rtc.py`(WebRTC 信令中继)— 以上模块均已收进 `delector/` 包(Phase 2,文件名不变,见仓库目录结构),`linguistics.py`/`core_dict.py`/`writing_rules.py`/`syntax_tree.py`/`prep_dict.py`/`database.py` 等同在其内;根目录只留 `start.py`/`package_windows.py`/`conftest.py`。 | +| 后端 | Python 3.10+, FastAPI, spaCy, genanki | `delector/server.py`(`create_app()` 装配:中间件 → `routes.register_routes(app)` → `init_db` → 静态挂载;`server.py:329`), `delector/routes/`(**v5.18 起按领域拆分**:`routes/__init__.py:register_routes` 统一 include,`main.router` 必须放在最后;旧名 `routes_a1.py`/`routes_sync.py`/`routes_rtc.py` 现为 `routes/a1.py`/`sync.py`/`rtc.py`),`linguistics.py`/`core_dict.py`/`writing_rules.py`/`syntax_tree.py`/`prep_dict.py`/`database.py` 等同在其内;根目录只留 `start.py`/`package_windows.py`/`conftest.py`。 | | 词法/形态学 | 556+ 不规则动词三态表 + 复合词递归拆解 | `linguistics.py`(1236 行) | | 离线核心词库 | 歌德 A1–B2,0ms 查词 | `core_dict.py`(516 行),`a1_dict.py`(A1 702 词与口语卡),`a1_writing_dict.py`(A1 写作题库) | | 介词搭配数据集 | 动词/形容词 + 固定介词 + 格 | `prep_dict.py`(生成物),入口 `lookup_prep_collocations()`,源 `tools/build_prep.py` | | 写作润色规则引擎 | 冠词/格位一致 + 介词支配格 + A1 填表与短电邮诊断 | `writing_rules.py`,入口 `analyze_essay_text()`, `analyze_a1_email()`, `check_a1_formular_answer()` | | 拓扑句法 | VF/LK/MF/RK/NF 五场域 + 从句 AST | `syntax_tree.py`(1238 行) | | 启动器 | 端口探测、局域网 IP、平台判定 | `start.py`(端口探测 / 局域网 IP / 平台判定 / 优雅退出;**行数不写**,旧值 86 行已过时——2026-10-16 实测 245 行) | -| 前端 | 原生 ES Modules(无框架、零构建),PWA | `static/index.html`, `static/js/`(**23 个模块,以 `ls static/js/*.js | wc -l` 为准**——旧值"10 个模块"漏掉 `listen-lab` / `hard-sentences` / `enc-i1` / `search` / `update` 等整块,2026-10-16 更正), `static/style.css` | +| 前端 | 原生 ES Modules(无框架、零构建),PWA | `static/index.html`, `static/js/`(**36 个模块,以 `ls static/js/*.js | wc -l` 为准**——2026-10-21 实测;v5.18 后端/前端解耦后从 23 增至 36。旧值"10 个模块"漏掉 `listen-lab` / `hard-sentences` / `enc-i1` / `search` / `update` 等整块,2026-10-16 更正), `static/style.css` | | 数据库 | SQLite × 2 | `delector.db`(主库), `progress.db`(学习进度)——**默认落在 `%LOCALAPPDATA%\DeLector\`**(不是仓库根;见 `ops.md` 的部署面) | | 音频缓存 | 本地 `.cache/audio/` MP3 | Edge Neural TTS(桌面 edge-tts;Android 无 wheel 时走 stdlib 版 `edge_tts_mini.py`)+ 有道/百度兜底 | | 桌面打包 | PyInstaller | `package_windows.py` | @@ -283,7 +283,7 @@ GET /api/wb/lan-info {hostname, port, lan_ip, instanc - **持久配对凭证 + 撤销**:`POST /api/wb/state/key` 重新生成密钥(仅本机)→ 旧 key 立即 403。 前端 `wbsync.pair.revoke()` 撤销后**必须换用服务端下发的新 key**:否则主机会被自己作废的 key 卡死(`pushNow` 开头 `if (!_key) return` 短路,连本机都推不动)。 -- **信令中继** `routes_rtc.py`:`POST/GET /api/wb/rtc/signal`,邮箱 id 取配对密钥的 sha256 摘要 +- **信令中继** `delector/routes/rtc.py`(旧名 `routes_rtc.py`):`POST/GET /api/wb/rtc/signal`,邮箱 id 取配对密钥的 sha256 摘要 (不用短码、也不留密钥明文);带 `sender` 隔离回声、带 `after` 游标防重放。 - **前端** `wbsync.rtc`:角色由「是否已配对」决定(已配对侧发 offer、宿主侧应答,免协商、不打架); 信封沿用 HTTP PUT 的 `{payload:...}` 形状——两种通道一套契约,少一处不一致的余地; @@ -344,6 +344,39 @@ R(t, S) = (1 + (19/81) * (t / S))^(-0.5) --- +## 桌面单实例与端口顺延(2026-10-22) + +**问题**:同一台机器上双击两次 = 两个进程抢同一个 SQLite 库;8000 被陌生程序占住 = 直接起不来。 +**判据**:一个 OS 级文件锁,**不是**"端口上有没有东西"(端口被占可能只是别的程序)。 + +| 项 | 值 | +| --- | --- | +| 锁文件 | `%LOCALAPPDATA%\DeLector\instance.lock`(POSIX:`~/.local/share/DeLector/instance.lock`) | +| 元数据 | 同目录 `instance.json`(pid / 版本 / 端口 / 数据目录 / 抢锁时刻) | +| 唯一判定点 | `start.claim_instance(env, requested_port=None, identity_probe=None)` | +| 端口候选 | `8000, 8002–8010` —— **8001 是 Go supervisor 保留端口**(`agent/internal/pythonsvc/doc.go`) | +| 显式 `--port` | **不顺延**;被占 ⇒ `AlreadyRunning(reason="port_busy")` + 退出码 3 | +| 归因优先级 | `unknown` > `version_mismatch` > `data_dir_mismatch` > `same` | + +**为什么锁与元数据是两个文件**:POSIX `flock` 锁的是 **inode** 而非路径。若对同一个文件 +`os.replace`,旧 fd 锁着已 unlink 的旧 inode,下一个进程打开新 inode 就能加锁 ⇒ **锁静默失效**。 +故 `instance.lock` 只做锁目标、永不替换;`instance.json` 走临时文件 + `os.replace`。 + +**为什么要有"第二腿"(身份探测)**:锁降级时(只读目录 / 无锁原语 / 文件打不开)没有权威判据, +此时「8000 被占」既可能是陌生程序、也可能是另一个 DeLector。前者该顺延,后者顺延就会**起第二个 +服务改同一个库**。故降级时先对候选端口做 `/api/health` 身份探测(`start._identity_probe`), +命中即判 `same`;**真锁在手时一律不探测**(锁是唯一判据,多花一次 HTTP 只是白等)。 + +**调用点**:`start.main()`(脚本 / `--server-only`)、`desktop.py::_check_port_or_fail`(桌面壳) +共用同一个判定点;三处各写一套就会漂成"壳说没有、服务说有两个"。 + +**退出码**:桌面壳 0 正常 / 1 就绪超时或异常 / 2 缺 WebView2 / 3 端口或实例冲突; +`--server-only` 走 `start.main()`,`same` 时打开已有页面并返回 0。 + +**排障**:`launch.log` 里搜 `拒绝启动()`;锁文件残留不会阻塞启动(进程死 ⇒ OS 自动放锁), +但 `instance.json` 可能停留在上一次的端口值 —— 判据永远以锁为准,不以 meta 为准。 + + ## 分层架构声明与单向依赖规范 > 依据 `01-Rules/LAYERED-ARCHITECTURE.md`,DeLector 后端采用严格的单向分层结构。 diff --git a/docs/agents/ops.md b/docs/agents/ops.md index d819d0d..a0bf2b5 100644 --- a/docs/agents/ops.md +++ b/docs/agents/ops.md @@ -52,6 +52,19 @@ NLP 模型: 优先 de_core_news_md,缺失则 de_core_news_sm(本机装的 在任务实跑时发生)。**dev 验证请走两个显式出口之一**:把 `DELECTOR_DATA_DIR` 指向临时目录,或设 `DELECTOR_PORTABLE=1` 保持"数据随程序目录"。两者都会让 bootstrap 不去搬仓库根那份真实库。 +**单实例锁(排障入口)** +: 同一台机器只允许一个 DeLector:`%LOCALAPPDATA%\DeLector\instance.lock`(POSIX + `~/.local/share/DeLector/instance.lock`)由 OS 文件锁守着,同目录 `instance.json` 记 + `pid / 版本 / 端口 / 数据目录 / 抢锁时刻`。判据永远是**锁**,不是"端口上有没有东西"。 + - 双击没反应 / 提示"已在运行":先看 `launch.log` 里的 `拒绝启动()`; + `same` = 同版本同目录的实例还活着(去开它给的地址),`version_mismatch` / + `data_dir_mismatch` = 另一份安装或另一份数据目录在跑,按提示先退出它。 + - **不要手工删 `instance.lock`**:进程死掉时 OS 已经放锁,删文件既无效也可能与正在启动的 + 实例抢同一个路径。真正该确认的是有没有残留 `DeLector.exe` 进程。 + - 端口:候选 `8000, 8002–8010`(**8001 留给 Go supervisor**)。自动模式顺延;显式 + `--port` 不顺延,被占就退出码 3。手机会拿到 `app.state.server_port` 的真实端口 + (`/api/wb/lan-info`),不是写死的 8000。 + **数据目录(部署面)** : 桌面端**默认 `%LOCALAPPDATA%\DeLector`**(Linux/macOS:`~/.local/share/DeLector`),由 `data_dir_bootstrap.resolve_data_dir()` 解析(优先级:`DELECTOR_DATA_DIR` → `DELECTOR_PORTABLE=1` @@ -220,6 +233,27 @@ TTS 链路(真机判定层):`AndroidNativeTTS.speak`(系统 TTS,免网 而**本仓文档里一行都没有** ⇒ 按 `AGENTS.md` 路由做 bootstrap 的 agent 根本读不到它。 **机械对照**:发版门(`tag == v+APP_VERSION`)是机械闸,所以那类错误会被构建当场抓住(同轮就抓到过一条 写死旧 tag 名的测试);分支纪律**没有机械闸** ⇒ 只能靠"写进可见文档" + 推送前自问一句「我现在在哪个分支」。 +13. **三类「看起来是缺陷、其实不是」的假红 + 一类「本地假绿」**(每类都烧过一整轮排查;先按下面取证,再决定要不要改): + - **行尾假阳性**:`core.autocrlf=true` 是**本机系统级**设置,工作区 CRLF / 索引 LF 是预期。 + 2026-10-21 实测 `git ls-files --eol` 有 **6 个 `i/lf w/mixed`**(3 份 `WORKMEMORY/archive/work-2026-09-*.log`、 + `delector/nlp_engine/linguistics.py`、`delector/services/syntax_score.py` 等)。**MUST NOT 据此批量 + `git add --renormalize` 或整体重排**:blob 本来就是 `i/lf`(历史干净),差异只在工作区;先 `git ls-files --eol` + 取证,再判断是不是真问题。 + - **终端编码不是服务端缺陷**:本机 Python 重定向/管道输出是 GBK(cp936),`print("✓")` 会在**产物已写出之后** + 抛 `UnicodeEncodeError`(交互式跑不出来,最坏情况是唯一的校验静默没跑)。跑任何会打非 ASCII 的脚本前设 + `PYTHONIOENCODING=utf-8`(本次全量实测即如此)。同理:**含非 ASCII 的 JSON 不要用 shell `curl` 发**, + 会被终端编码改写后变成 body 解析错;验证 HTTP 契约走 `TestClient` / 客户端库。 + - **退出码 0 ≠ 正确**:本仓所有已知静默失效(空图表、计数器翻倍、拒单、标签丢失、探针恒真)都曾经全绿通过。 + 自报口径:`pytest` 的 passed 数(不是"没报错")、探针自带的切片/簇形状护栏、六道机械闸、`--check` 类脚本的 + 非空 diff、以及 `WORKMEMORY/PROJECT_OVERVIEW.md` 的「现测基线」。**判据本身是否在跑也要证**(变异自证用例)。 + - **本地假绿:门禁命令漏一个 flag ⇒ 只有 CI 红**(2026-10-22 实例)。四档 mypy 的参数**各不相同**, + 「`--platform win32 --strict` 对了」不代表「`--follow-imports=skip` 也对了」: + `ci.yml` 的入口面那档是 `mypy --platform win32 --strict --follow-imports=skip desktop.py package_windows.py conftest.py start.py`。 + 漏 `--follow-imports=skip` 时被跳过的模块会被**真正解析**(有类型)⇒ 全绿;带上它则符号退化成 `Any`, + 「把 import 进来的调用/常量直接 `return`」立刻变成 `no-any-return`(本次三处:`start.py` 的 + `_identity_probe` / `_app_version`、`desktop.py` 的 `_identity_probe`)。修法不是加 `type: ignore`, + 而是**显式落到带注解的局部变量再 return**。**纪律:复跑门禁 Must 直接从 `ci.yml` 复制 `run:` 那一行**, + 不凭记忆重打;改动入口面文件(`start.py` / `desktop.py` / `conftest.py` / `package_windows.py`)时尤其。 --- diff --git a/docs/plans/2026-10-21-desktop-single-instance.md b/docs/plans/2026-10-21-desktop-single-instance.md new file mode 100644 index 0000000..2399512 --- /dev/null +++ b/docs/plans/2026-10-21-desktop-single-instance.md @@ -0,0 +1,406 @@ +# 桌面单实例与端口顺延 Implementation Plan + +> **Goal**: 让「同一台机器上只能跑一个 DeLector 实例」成为 OS 级事实(文件锁),并把端口占用从「启动失败」变成「顺延到 8002–8010」。 +> **Tech Stack**: Python 3.11 / FastAPI / uvicorn / pytest;Windows(`msvcrt`)+ POSIX(`fcntl`)。 +> **Spec Reference**: `docs/specs/2026-10-21-desktop-single-instance-design.md`(APPROVED);上游 ADR-0019 §6.4、ADR-0020 P0-1。 +> **Global Constraints**: +> - 代码改动 MUST 走分支 + PR(`docs/agents/ops.md` 第 12 条);本计划分支见下。 +> - `delector/core/instance_lock.py` **不得**在模块级 import `database` / FastAPI。 +> - 不得新增裸 `except: pass`(`tools/check_silent_degradation.py`)。 +> - 面向用户的文案禁含堆栈与数据目录绝对路径以外的内部标识;`data_dir` **绝不**进任何 HTTP 响应(红线⑦同类)。 +> - 验证一律 `PYTHONIOENCODING=utf-8`,tests 单进程串行(并行会争数据目录守卫造成假红)。 +> **Branch**: `feat/desktop-single-instance`(从 `master@85fb029` 开,经 PR 合入)。 +> **Repo Invariants Pointer**: `WORKMEMORY/PROJECT_OVERVIEW.md`(§红线速查)+ `docs/agents/ops.md`(第 12/13 条)。每个 subagent brief 引用这两个路径,**不重述**。 + +--- + +## ☑️ Decisions So Far + +- 锁位置 = **全局** `%LOCALAPPDATA%\DeLector\`(ADR-0019 §6.4);否决 per-data-dir(spec §0/§6)。 +- 端口顺延 = **8000, 8002–8010**(跳过 8001:`agent/internal/pythonsvc/doc.go:9-10`)。 +- 身份探测降为「锁不可用」时的第二腿;锁是唯一判据。 +- **锁与元数据分两个文件**(plan 阶段发现的 POSIX 硬约束,已回写 spec §3.1): + `instance.lock` 只做 flock 目标、永不替换;`instance.json` 走临时文件 + `os.replace`。 + 理由:`os.replace` 会把 fd 锁指向已 unlink 的旧 inode ⇒ 下一个进程能抢到锁,**锁静默失效**。 +- `--port` 显式给定时**不顺延**(显式意图优先,最小惊讶);自动模式才顺延。 +- `--server-only` 复用 `start.main()` ⇒ 自动继承锁,无需单独接线。 +- `lan-info` 端口来源改为 `app.state.server_port`,默认 8000(TestClient/Android 行为不变);**不用** `request.url.port`(可被 `Host` 伪造)。 + +--- + +## ☑️ Active Frontier (Unblocked Tasks) + +> **File-Occupancy**: 本计划**串行单分支执行**,无多 subagent 并发 ⇒ 每个任务对下表文件拥有独占窗口,任务结束时释放。禁止把两个任务派给并发 subagent。 + +| 文件 | 独占任务 | +| --- | --- | +| `delector/core/instance_lock.py`(新) | Task 1, 2 | +| `tests/test_instance_lock.py`(新) | Task 1, 2, 3 | +| `start.py` | Task 4, 6 | +| `tests/test_start.py` | Task 4, 6 | +| `tests/test_desktop_data_dir_bootstrap.py` | Task 5 | +| `desktop.py` / `tests/test_desktop_port_guard.py` | Task 7 | +| `delector/core/server_lifecycle.py` / `delector/routes/backup.py` / `tests/test_server_lan.py` | Task 8 | +| `docs/**`、`WORKMEMORY/**` | Task 9 | + +--- + +### Task 1: 锁记录与元数据原子读写 [Tier: 2-Standard] [Mode: AFK] [Role: TDD Builder] + +**Files:** +- Create: `delector/core/instance_lock.py` +- Test: `tests/test_instance_lock.py` + +**Interfaces:** +- Consumes: `delector.core.data_dir_bootstrap`(仅用于对齐 env 读取风格,不调用);`pathlib`、`json`、`os`。 +- Produces: + ```python + @dataclass(frozen=True) class LockRecord: pid:int; version:str; port:int; data_dir:str; acquired_at:str + @dataclass(frozen=True) class LockPaths: + lock: str; meta: str + @staticmethod + def for_platform(env: Mapping[str,str]) -> "LockPaths" + def read_record(paths: LockPaths) -> Optional[LockRecord] # 缺失/半截/非 JSON ⇒ None + def write_record(handle: "LockHandle", record: LockRecord) -> None # 临时文件 + os.replace + ``` + +**Injected Instincts:** +- [ ] `[Instinct: Atomic IO]`: 写 meta MUST 用 `tempfile.NamedTemporaryFile(dir=同目录, delete=False)` + `os.replace`;失败分支 `finally` 删临时文件。 +- [ ] `[Instinct: Import Boundary]`: 模块顶层只允许 stdlib;**禁止** `delector.database`(Freeze DATA_DIR 风险)。 + +**Subagent Prompt Scaffold (for /dfs-exec):** +> "Implement Task 1: 锁记录与元数据原子读写. Mode: AFK | Role: TDD Builder. +> Target: Create `delector/core/instance_lock.py`, Test `tests/test_instance_lock.py`. +> RED: 写 3 条测试 — (a) `write_record` 后 `read_record` 往返等值;(b) meta 是半截 JSON / 不存在 ⇒ `None`(不抛);(c) `for_platform` 在 Windows env(`LOCALAPPDATA=C:\Users\x\AppData\Local`) / POSIX env(`HOME=/home/x`) 下返回 `/instance.lock` 与 `/instance.json`,且 **锁文件名永不等于 meta 文件名**。 +> GREEN: 最小实现(dataclass + json + os.replace)。Refactor: guard clause 压平,最多 2 层缩进。 +> Return: 摘要 + 强制物理回执(命令 + exit code + passed count)。" + +**Step Breakdown:** +- [ ] Step 1: 写 `tests/test_instance_lock.py` 三条 RED +- [ ] Step 2: `python -m pytest tests/test_instance_lock.py -q` 确认失败(ModuleNotFoundError) +- [ ] Step 3: 最小实现(GREEN) +- [ ] Step 4: 重跑,全绿 +- [ ] Step 5: REFACTOR(压平、去重复) +- [ ] Step 6: 物理回执(命令 + exit code + passed 数 + `git diff --stat`) +- [ ] Step 7: 原子提交到 `feat/desktop-single-instance` + +--- + +### Task 2: 跨进程互斥原语 [Tier: 2-Standard] [Mode: AFK] [Role: TDD Builder] + +**Files:** +- Modify: `delector/core/instance_lock.py` +- Test: `tests/test_instance_lock.py` + +**Interfaces:** +- Consumes: Task 1 的 `LockPaths` / `LockRecord` / `read_record`。 +- Produces: + ```python + @dataclass class LockHandle: paths: LockPaths; record: LockRecord; def release(self) -> None + def try_acquire(paths: LockPaths, record: LockRecord) -> LockHandle | LockRecord + ``` + 语义:拿到锁 ⇒ `LockHandle`;被持有 ⇒ 对方 `LockRecord`(读不到 ⇒ `LockRecord(pid=0, version="unknown", port=0, data_dir="", acquired_at="")`)。 + +**Injected Instincts:** +- [ ] `[Instinct: OS Lock Semantics]`: Windows `msvcrt.locking(fd, LK_NBLCK, 1)` 前 MUST `seek(0)`;POSIX `fcntl.flock(fd, LOCK_EX|LOCK_NB)`。锁 fd 全程保持打开,`release()` 幂等。 +- [ ] `[Instinct: Loud Degradation]`: 两种原语都不可用 ⇒ 仍返回 `LockHandle`,但 `logging.warning` 一次(文案含「本次不是真互斥」),**不静默**。 + +**Subagent Prompt Scaffold (for /dfs-exec):** +> "Implement Task 2: 跨进程互斥原语. Mode: AFK | Role: TDD Builder. +> RED 4 条:(a) 两个独立 fd 顺序 `try_acquire` ⇒ 第二次返回第一条的 `LockRecord`(pid/version 一致);(b) `release()` 后可再取,且重复 `release()` 不抛;(c) meta 被删/损坏但锁被持有 ⇒ 返回 unknown 记录且 pid==0;(d) `write_record` 后再 `try_acquire`(另一 fd)仍失败 —— 证明回写**没有**破坏锁(防 os.replace 失效回归)。 +> GREEN: 实现 `try_acquire`/`release`/降级 warning。禁止 `except: pass`。 +> Return: 回执(exit code + passed 数)。" + +**Step Breakdown:** +- [ ] Step 1: 4 条 RED 测试 +- [ ] Step 2: 验证失败 +- [ ] Step 3: GREEN +- [ ] Step 4: 全绿 +- [ ] Step 5: REFACTOR +- [ ] Step 6: 物理回执 +- [ ] Step 7: 提交 + +--- + +### Task 3: 真子进程互斥验证 [Tier: 2-Standard] [Mode: AFK] [Role: TDD Builder] + +**Files:** +- Test: `tests/test_instance_lock.py`(追加一节 `# ── 跨进程(真 fork/spawn)──`) + +**Interfaces:** +- Consumes: `subprocess.run([sys.executable, "-c", SCRIPT], env=...)`;`sys.executable`。 +- Produces: 仅测试,无生产接口。 + +**Subagent Prompt Scaffold (for /dfs-exec):** +> "Implement Task 3: 真子进程互斥验证. Mode: AFK | Role: TDD Builder. +> 测试内容:父进程用 `subprocess.Popen` 起一段内联脚本(`python -c`),子进程 `try_acquire` 后打印 `HELD ` 并 `time.sleep(30)`;父进程等 `HELD` 读到后 `try_acquire` 必须失败且 pid 匹配;随后 `terminate()` 子进程、`wait()`,再试必须成功。 +> 约束:超时上界 10s(`communicate(timeout=...)`),失败信息含子进程 stdout/stderr;不得使用裸 `sleep` 轮询超过 10 次。 +> 理由:现有 desktop 测试全是 monkeypatch,盖不住真锁语义。Return: 回执。" + +**Step Breakdown:** +- [ ] Step 1: 写测试(此时 Task 2 已实现 ⇒ 首跑即绿,**属预期**;本条不是 TDD 红灯,是「真进程语义」验证) +- [ ] Step 2: **变异自证**:临时把 `try_acquire` 改成直接返回 `LockHandle`(跳过 OS 锁),确认该用例**转红**;还原后复绿。回执需含两次 exit code +- [ ] Step 3: 记录耗时(应 < 5s) +- [ ] Step 4: 物理回执 +- [ ] Step 5: 提交 + +--- + +### Task 4: `start.claim_instance` 单一判定点 [Tier: 2-Standard] [Mode: AFK] [Role: TDD Builder] + +**Files:** +- Modify: `start.py`(新增 `claim_instance` / `Ready` / `AlreadyRunning` / `PORT_CANDIDATES`;`is_port_in_use` 保持不动) +- Test: `tests/test_start.py`(新增用例类) + +**Interfaces:** +- Consumes: `instance_lock.LockPaths.for_platform` / `try_acquire`;`data_dir_bootstrap.resolve_data_dir`;`version.APP_VERSION`;`start.is_port_in_use`。 +- Produces: + ```python + PORT_CANDIDATES: tuple[int, ...] = (8000, 8002, 8003, 8004, 8005, 8006, 8007, 8008, 8009, 8010) + @dataclass(frozen=True) class Ready: port: int; lock: instance_lock.LockHandle + @dataclass(frozen=True) class AlreadyRunning: + record: instance_lock.LockRecord; reason: str # "unknown" | "version_mismatch" | "data_dir_mismatch" | "same" + def claim_instance(env: Mapping[str,str], requested_port: Optional[int] = None) -> Ready | AlreadyRunning + ``` + 判定优先级:`unknown` > `version_mismatch` > `data_dir_mismatch` > `same`。 + `requested_port` 给定时**只试它**(占用 ⇒ 返回 `AlreadyRunning(record=unknown, reason="port_busy")` 的新 reason 值)。 + +**Injected Instincts:** +- [ ] `[Instinct: No Silent Fallback]`: 顺延必须写一条日志(`print` 到 stderr 或 logging),文案含「8000 被占用,改用 800X」。 +- [ ] `[Instinct: Purity]`: 端口探测通过注入的 `probe` 参数(默认 `start.is_port_in_use`)以便测试,**不要** patch 全局。 + +**Subagent Prompt Scaffold (for /dfs-exec):** +> "Implement Task 4: `start.claim_instance`. Mode: AFK | Role: TDD Builder. +> RED 6 条(monkeypatch `is_port_in_use` 与 `LockPaths.for_platform` 指向 tmp_path): +> 1) 8000 空闲 ⇒ Ready(8000)。 +> 2) 8000 占、8001 占、8002 空闲 ⇒ Ready(8002)(哨兵:**永不**选 8001)。 +> 3) 8000–8010 全占 ⇒ AlreadyRunning(reason='no_free_port')。 +> 4) 锁被持有且 version 不同 ⇒ reason='version_mismatch',record 带对方版本。 +> 5) 锁被持有且 data_dir 不同 ⇒ reason='data_dir_mismatch'。 +> 6) `requested_port=9000` 且占用 ⇒ reason='port_busy',**不**落到 9001。 +> GREEN: 实现(顺序:resolve_data_dir → try_acquire → 选端口)。注意 target 端口在拿到锁之后才探测。 +> Return: 回执(exit code + 6 passed)。" + +**Step Breakdown:** +- [ ] Step 1: 6 条 RED +- [ ] Step 2: 验证失败 +- [ ] Step 3: GREEN +- [ ] Step 4: 全绿(单测文件 < 3s) +- [ ] Step 5: REFACTOR(端口选择抽成 `_pick_port(probe, requested)`) +- [ ] Step 6: 物理回执 +- [ ] Step 7: 提交 + +--- + +### Task 5: 启动顺序守卫放行 + 窄守卫 [Tier: 2-Standard] [Mode: HITL] [Role: Guard Editor] + +> HITL:这条任务修改的是**既有安全闸**,需人工过目 diff 后再继续。 + +**Files:** +- Modify: `tests/test_desktop_data_dir_bootstrap.py:402-409`(`_is_delector_import` 白名单加 `delector.core.instance_lock`) +- Modify: `tests/test_desktop_data_dir_bootstrap.py`(新增 `test_instance_lock_module_does_not_import_database`) + +**Interfaces:** +- Consumes: 现有 `_delector_imports` / `test_bootstrap_module_does_not_import_database_at_module_level`(:478)的断言写法。 +- Produces: 无生产接口。 + +**注(必须写进测试注释)**:`test_start_py_bootstrap_precedes_every_delector_import` 取的是**全模块最小行号**, +而 `start.py:39` 的 `data_dir_bootstrap` 导入永远是最小值 ⇒ 新加的 `instance_lock` 导入**不会**让它变红。 +加白名单是为了「意图显式」:下一个人看到 `instance_lock` 在 bootstrap 之前被导入,能查到这是批准过的例外。 +**不要**把这条守卫改成「逐行比较」(会连带要求把 `probe_identity` 的惰性导入挪位置,超出本计划)。 + +**Subagent Prompt Scaffold (for /dfs-exec):** +> "Implement Task 5. Mode: HITL | Role: Guard Editor. +> 1) 在 `_is_delector_import` 的白名单里加 `delector.core.instance_lock`,注释写清「不 import database,可先于 bootstrap」。 +> 2) 新增 `test_instance_lock_module_does_not_import_database`:读 `delector/core/instance_lock.py` 源码,断言模块级无 `import delector.database` / `from delector.database` / `from delector import database`。 +> 3) 断言既有两条守卫仍绿:`python -m pytest tests/test_desktop_data_dir_bootstrap.py -q`。 +> Return: 回执(命令 + exit code)。" + +**Step Breakdown:** +- [ ] Step 1: 写新守卫测试(此时 `instance_lock.py` 已存在 ⇒ 首跑即绿,**属预期**) +- [ ] Step 2: **变异自证**:临时给 `instance_lock.py` 顶层加一行 `import delector.database`,确认新守卫**转红**;删掉后复绿(回执含两次 exit code) +- [ ] Step 3: 白名单 + 注释 +- [ ] Step 4: 全文件绿(含原有 2 条顺序守卫) +- [ ] Step 5: HITL:把 diff 交给用户过目 +- [ ] Step 6: 提交 + +--- + +### Task 6: `start.main()` 接线 [Tier: 2-Standard] [Mode: AFK] [Role: TDD Builder] + +**Files:** +- Modify: `start.py`(`main()` 内:`claim_instance` → 端口 → `bootstrap_data_dir` → serve;`finally` 释放锁) +- Test: `tests/test_start.py` + +**Interfaces:** +- Consumes: `claim_instance`(Task 4)、`bootstrap_data_dir(env)`、`on_serving` 回调(既有)。 +- Produces: `main(argv: Optional[list[str]] = None, on_serving: Optional[Callable[[int], None]] = None) -> None`(签名不变)。 + +**Injected Instincts:** +- [ ] `[Instinct: In-Process Lock Leak]`: 测试在**同进程**里调 `main()` ⇒ MUST 在 `finally` 调 `lock.release()`,否则后续用例全被误判为「已在运行」(假红)。 +- [ ] `[Instinct: Ordering]`: `claim_instance` 调用 MUST 出现在 `bootstrap_data_dir` **之前**;`from delector.server import app` 保持现状位置(更晚)。 + +**Subagent Prompt Scaffold (for /dfs-exec):** +> "Implement Task 6. Mode: AFK | Role: TDD Builder. +> RED 3 条:(a) `main()` 在锁被持有(version 不同)时不 import `delector.server`、退出码 1、stderr 含双方版本;(b) 8000 被无关程序占 ⇒ `on_serving` 收到 8002(用 monkeypatch 注入 claim 结果的替代:patch `start.claim_instance`);(c) `main()` 正常结束(KeyboardInterrupt)后同进程可再次 `try_acquire`(证明 finally 释放)。 +> GREEN: 接线。AlreadyRunning 文案:`same` ⇒ 「DeLector 已在运行(v{ver},pid {pid}),端口 {port}」+ 打开浏览器;其余 ⇒ 说明差异并 exit 1。 +> 约束:不新增裸 except;类型注解齐全(mypy --strict 覆盖 start.py)。 +> Return: 回执。" + +**Step Breakdown:** +- [ ] Step 1: 3 条 RED +- [ ] Step 2: 验证失败 +- [ ] Step 3: GREEN +- [ ] Step 4: `python -m pytest tests/test_start.py tests/test_start_lifecycle.py -q` 全绿 +- [ ] Step 5: `python -m mypy --strict start.py` +- [ ] Step 6: 物理回执 +- [ ] Step 7: 提交 + +--- + +### Task 7: 桌面壳接线 [Tier: 3-Critical] [Mode: HITL] [Role: TDD Builder] + +> Critical:改动 pywebview 启动主链与退出码契约,需人工确认「弹窗文案 + 退出码 + 锁释放」三件事。 + +**Files:** +- Modify: `desktop.py`(`_parse_args` 默认 `None`;`run_desktop(port: Optional[int] = None, ...)`;`_run_desktop_inner(port, log_path)` 首步 claim;`dispatch` 的 `--server-only` 警告条件;`_check_port_or_fail` 移除或改为薄包装) +- Test: `tests/test_desktop_port_guard.py` + +**Interfaces:** +- Consumes: `start.claim_instance`(惰性 import,沿用 `desktop.py:887` 既有注释的理由)、`write_crash`、`_append_log`。 +- Produces: `run_desktop(port: Optional[int] = None, debug_console: bool = False) -> int`;`_run_desktop_inner(port: Optional[int], log_path: str) -> int`。 + 端口语义:`None` = 自动(8000 起顺延);整数 = 用户显式指定(不顺延)。 + 退出码不变:0 正常 / 1 就绪超时或异常 / 2 缺 WebView2 / 3 端口或实例冲突。 + +**Injected Instincts:** +- [ ] `[Instinct: Backward Compat]`: 既有测试以 `_run_desktop_inner(8000, log)` 位置参数调用 ⇒ 保持位置参数顺序不变;`run_desktop(port=8000)` 语义变为「显式 8000」,与这些用例的意图一致。 +- [ ] `[Instinct: No Stack In UI]`: 弹窗只给人话(版本、pid、数据目录、日志路径),traceback 只进 launch.log。 + +**Subagent Prompt Scaffold (for /dfs-exec):** +> "Implement Task 7. Mode: HITL | Role: TDD Builder. +> RED 5 条:(a) 锁被持有(reason='same') ⇒ 返回 3、弹窗文案含 pid 与版本、**不**调用 build_server;(b) reason='version_mismatch' ⇒ 返回 3、文案含双方版本;(c) 自动模式 8000 占 ⇒ build_server 收到 8002(patch claim 返回 Ready(8002));(d) 显式 `run_desktop(port=8000)` 且占用 ⇒ 返回 3(不顺延);(e) `_run_desktop_inner` 所有返回路径后锁可用(同进程二次 try_acquire 成功)。 +> GREEN: 接线,保留 `--server-only` 走 start.main 的既有路径(不要在 dispatch 里再加锁)。 +> Refactor: 失败文案抽成一个 `_describe_holder(record, reason) -> str`,避免四处复制。 +> Return: 回执(5 passed + mypy)。" + +**Step Breakdown:** +- [ ] Step 1: 5 条 RED +- [ ] Step 2: 验证失败 +- [ ] Step 3: GREEN +- [ ] Step 4: `python -m pytest tests/test_desktop_port_guard.py tests/test_desktop_shell.py tests/test_desktop_window_lifecycle.py tests/test_desktop_tray_exit.py -q` +- [ ] Step 5: `python -m mypy --strict desktop.py splendid`(按仓库既有 mypy 目标;若 desktop 不在目标内则跑全套 `--strict delector tools`) +- [ ] Step 6: HITL:呈交 3 条弹窗文案 + 退出码表给用户确认 +- [ ] Step 7: 提交 + +--- + +### Task 8: `lan-info` 真实端口 [Tier: 2-Standard] [Mode: AFK] [Role: TDD Builder] + +**Files:** +- Modify: `delector/core/server_lifecycle.py:55-71`(`build_server` 内 `app.state.server_port = port`,用 `getattr(app, "state", None)` 保护) +- Modify: `delector/routes/backup.py:179`(`"port": getattr(request.app.state, "server_port", 8000)`) +- Test: `tests/test_server_lan.py` + +**Interfaces:** +- Consumes: `uvicorn.Config(app, ...)` 既有装配。 +- Produces: `build_server(host, port, app) -> uvicorn.Server`(签名不变,副作用新增 `app.state.server_port`)。 + +**Subagent Prompt Scaffold (for /dfs-exec):** +> "Implement Task 8. Mode: AFK | Role: TDD Builder. +> RED 2 条:(a) `client.app.state.server_port = 8007` ⇒ `/api/wb/lan-info` 的 `port` 字段为 8007;(b) 不设 state ⇒ 仍为 8000(兼容 TestClient/Android)。 +> GREEN: 两处各改一行。断言 `build_server` 也写入(用 `delector.server.app` 建 server 再读 state)。 +> Return: 回执(2 passed)。" + +**Step Breakdown:** +- [ ] Step 1: 2 条 RED +- [ ] Step 2: 验证失败 +- [ ] Step 3: GREEN +- [ ] Step 4: `python -m pytest tests/test_server_lan.py tests/test_start_lifecycle.py -q` +- [ ] Step 5: 物理回执 +- [ ] Step 6: 提交 + +--- + +### Task 9: 文档同步 [Tier: 1-Light] [Mode: AFK] [Role: Docs] + +**Files:** +- Modify: `docs/agents/architecture.md`(新增「桌面单实例与端口」小节:锁路径、端口表 8000/8001/8002–8010、`--port` 不顺延) +- Modify: `docs/agents/ops.md`(本机环境:`%LOCALAPPDATA%\DeLector\instance.lock` 排障入口) +- Modify: `WORKMEMORY/PROJECT_OVERVIEW.md`(开放待办:删除该条 / 标已交付)+ `WORKMEMORY/work.log`(DECISION 事件) + +**Interfaces:** Consumes: spec/plan 路径。Produces: 无。 + +**Subagent Prompt Scaffold (for /dfs-exec):** +> "Implement Task 9. Mode: AFK | Role: Docs. +> 只改上述 4 个文件;`grep -c "8001"` 确认端口表把 8001 标为 supervisor 保留;`grep -n "instance.lock"` 三个文件都能命中。Return: grep 回执 + diff stat。" + +**Step Breakdown:** +- [ ] Step 1: 四处文档编辑 +- [ ] Step 2: grep 回执(非空) +- [ ] Step 3: 提交(纯文档,可直接进 PR 同一个 commit 序列) + +--- + +### Task 10: 全闸验证与 PR [Tier: 2-Standard] [Mode: HITL] [Role: Integrator] + +**Files:** 无代码改动(仅修红)。 + +**Interfaces:** Consumes: 全部前置任务。Produces: PR。 + +**Step Breakdown:** +- [ ] Step 1: `PYTHONIOENCODING=utf-8 python -m pytest tests/ -q`(期望:2517 + 新增 ≈ 23 passed / 0 failed) +- [ ] Step 2: `ruff check .` +- [ ] Step 3: `python -m mypy --strict delector tools` + `python -m mypy --follow-imports=skip tests` +- [ ] Step 4: 六道闸:`check_layer_invariants` / `check_mock_drift` / `check_silent_degradation` / `check_frontend_api_contract` / `check_frontend_layers` / `check_index_html_sync` +- [ ] Step 5: 真双启动手测(HITL):桌面双击两次、`python start.py` 在桌面实例运行时、8000 被占时顺延、`--port 9000` 被占时报错 +- [ ] Step 6: `gh pr create`(标题 `feat: desktop single-instance lock and port fallback`),body 引用 spec + 本计划 +- [ ] Step 7: PR 由用户 review 后合入(**不**自行 merge) + +--- + +## ☁️ Fog of War (Not Yet Specified) + +无。spec §6 已把全部未知收敛为决定或明确不做;本计划无待上游毕业的条目。 + +--- + +## ☑️ Deferred Gap Register + +| gap | why deferred | trigger that forces revisiting | owner | +| --- | --- | --- | --- | +| 已有实例时把窗口提到前台 | 需 Win32 `FindWindow`/`SetForegroundWindow` 与窗口标题契约,属另一能力线 | 用户反馈「双击没反应 / 窗口找不到」成为重复投诉 | 桌面壳后续立项 | +| Go supervisor(8001)不受锁保护 | 牵动崩溃退避与预算闸,本次不碰 | 出现「supervisor 与桌面实例同库并发导致数据损坏」的证据 | agent 服务线 | +| `is_port_in_use` 单次 ~2s | ADR-0018 Unknown 8 的独立议题 | 冷启动时延成为用户可感知投诉 | ADR-0018 计划 | +| per-data-dir 多实例(便携版+安装版并存) | YAGNI,会引入双窗口/双库/双托盘 | 用户明确要求两版并存 | 新 spec | +| `probe_identity` 只比 `app` 不比版本 | 既有契约,收紧会波及 `await_ready` 与桌面壳 | 出现「身份对但版本错」的真实误判 | ADR-0020 后续 | + +--- + +## ☑️ Out of Scope + +- 窗口激活 / 单例转发(见 gap 表)。 +- Go supervisor 加锁、`pythonsvc` 端口改造。 +- `is_port_in_use` 探测成本优化、spaCy 冷启动提速。 +- Android(`bootstrap_data_dir` 返回空串时 `claim_instance` 直接 `Ready(8000)`,不取锁)。 +- 前端任何改动。 + +--- + +## Deviation Table (fill before merge) + +| plan text | what was actually built | why | +| --- | --- | --- | +| 执行顺序 Task 4 → Task 5 | **Task 5 先于 Task 4** | `test_start_py_bootstrap_precedes_every_delector_import` 取全模块最小行号:`start.py` 顶层一旦新增 `delector.core.instance_lock` 导入,最小值就从 `main()` 内的 309 掉到 ~44 ⇒ Task 4 必须先拿到 Task 5 的白名单放行,否则它的「全绿」验收物理上不可达。 | +| Task 6 Interfaces:`main(argv, on_serving)` | `main(debug_console, on_serving)`(**未改签名**) | 计划该行是笔误:仓库实际签名与全部调用方都是 `debug_console` 版本,按「签名不变」执行。 | +| Task 7「`_check_port_or_fail` 移除或改为薄包装」 | 保留函数名,改成 `claim_instance` 的薄封装(出参 dict 交出 port/lock) | 两处 AST 守卫以该函数名为锚(`test_desktop_window_lifecycle.py` 的阶段标记夹逼、`test_desktop_port_guard.py` 的跨函数顺序)。改名会让守卫**找不到锚点**,属"判据失效"而非"实现变更"。 | +| (未写)`APP_VERSION` 的取法 | `start.py::_app_version()` 惰性导入,且**定义在 `main()` 之后** | 同上那条顺序守卫:`delector.core.version` 不在 `_BOOTSTRAP_SAFE_IMPORTS` 白名单,模块级导入会让最小值下移。**已登记技术债**:把 `delector.core.version` 加进白名单,让该 helper 回到 `claim_instance` 旁边(当前规避手法依赖行号算术,脆弱)。 | +| Task 9 只提「四处文档编辑」 | 另改两个测试的文件形状(**不放宽断言**) | ① `tests/server_helpers.py` 新增 `stmts_through_try`:Task 7 用 `try/finally` 放锁后阶段标记下沉一层,且容器自身会因子树包含调用而误命中锚点;② `tests/test_desktop_shell.py` 两条用例原靠 patch `is_port_in_use` 拦启动,判定点换成锁后桩失效 ⇒ 真起服务**挂死**(实测 pytest 超时 124),改到 `claim_instance`。 | +| (未写)`check_mock_drift` | 3 处 `mock-drift-ok` 豁免 | 闸判据只看"目标模块内部是否还引用该符号",而 `LockPaths.for_platform` 的消费方在 `start.py::claim_instance`(跨模块、调用期类属性查找)⇒ 属误报。豁免行逐行可审。 | + +--- + +## Ledger + +仓库内**没有** `scripts/vault_exec_state.py`(该脚本不在本仓)。交接方式: +`/dfs-exec` 按 `.vault-exec-ledger.json` 既有 schema(`version/plan_file/tasks[id,title,status,...]`)写入, +`plan_file` = `docs/plans/2026-10-21-desktop-single-instance.md`,共 10 个任务。 diff --git a/docs/specs/2026-10-21-desktop-single-instance-design.md b/docs/specs/2026-10-21-desktop-single-instance-design.md new file mode 100644 index 0000000..a8292f2 --- /dev/null +++ b/docs/specs/2026-10-21-desktop-single-instance-design.md @@ -0,0 +1,257 @@ +# 桌面单实例与端口顺延 · 设计稿 + +> **性质**:设计稿(`docs/specs/`),不是实施计划。落地时另立 `docs/plans/` 计划。 +> **状态**:APPROVED(2026-10-21,用户批准;同日修正 §3.1 为「锁/元数据双文件」,理由见该节引用块)。 +> **实施计划**:`docs/plans/2026-10-21-desktop-single-instance.md`。 +> **上游**:ADR-0019 §6.4 第 4 条(`docs/adr/2026-10-10-adr-0019-windows-desktop-pwa-first-and-data-dir-externalization.md:141`, +> 原文:`%LOCALAPPDATA%\DeLector\instance.lock`(pid + 版本 + 数据目录);探测改为验证 `/api/health` 带 +> DeLector 标识,不匹配则顺延 `8001–8010` —— **单实例靠文件锁而非端口探测**)。该条当年只登记、未实施。 +> **相关**:ADR-0020 §6「health 探测必须校验身份与版本」(已实施为 `probe_identity`,**不含版本**)。 +> **基线**:master `85fb029`(本轮 onboard 实测 2517 passed + 2 skipped)。 + +--- + +## 0. 结论先行 + +**采用全局文件锁 `%LOCALAPPDATA%\DeLector\instance.lock`,锁内容是唯一真相源;端口顺延范围改为 `8002–8010`。** + +三处**在对话里给出、但本稿推翻**的初判(写错的接口比写漏的更危险,先标出来): + +| 初判 | 本稿结论 | 依据 | +| --- | --- | --- | +| 锁文件放**数据目录**内(per-data-dir 单实例,允许便携版与安装版并存) | **放平台默认目录,全局唯一** | ADR-0019 §6.4 已登记该位置;且「一个用户 = 一个 DeLector」是单机学习工具的诚实模型。per-data-dir 会引入「两个窗口、两个库、两份托盘」的新状态面,收益未测 | +| 顺延 `8001–8010` | **`8002–8010`** | `8001` 已被 Go supervisor 占用为保留端口:`agent/internal/pythonsvc/doc.go:9-10`「127.0.0.1:8001(8000 留给用户直启实例)」、`supervisor.go:32-43` | +| 身份探测仍作为同数据目录的判据 | **降为第二腿**(只在锁不可用时使用) | 启动期竞争:服务未就绪时 `probe_identity` 为假,会把「自己正在启动」误报成「端口被其他程序占用」 | + +--- + +## 1. 实测基线 & 问题陈述 + +### 1.1 今天真实存在的四条失败路径(全部带现场) + +| # | 现象 | 现场 | +| --- | --- | --- | +| F1 | 端口被占且对方是 DeLector ⇒ **无条件复用并打开它的页面**,不看版本、不看数据目录 | `start.py:203` `_handle_existing_port` → `probe_identity(port)` 为真即 `open_browser` | +| F2 | **启动期竞争**:空闲端口上 `is_port_in_use` 在 Windows 实测耗时 **~2.03s**(ADR-0020 §124),而健康端点此时可能尚未就绪(spaCy 冷启动)⇒ `probe_identity` 为假 ⇒ 报「端口被其他程序占用」并 `exit(1)`。**诊断指向了不存在的元凶** | `start.py:137` → `:203`;`server_lifecycle.py:116` | +| F3 | 双版本并存时无法告出版本:桌面壳的 P0-1 守卫只能判「是不是 DeLector」,判不出「是旧版本」 | `desktop.py:873` `_check_port_or_fail`,`:889` 只调 `probe_identity`;身份载荷只有 `app`+`version`(`routes/main.py:580`),且 `_has_identity` **只比对 `app`**(`server_lifecycle.py:111-113`) | +| F4 | `8000` 被**无关程序**占用 ⇒ 两条入口都直接失败,无顺延 | `start.py:66` `is_port_in_use`;`desktop.py:873` | + +另有两条**既有缺陷**与本设计直接耦合: + +- **L1 硬编码端口外泄**:`/api/wb/lan-info` 返回写死的 `"port": 8000`(`delector/routes/backup.py:179`)。一旦启用顺延,手机端会拿到**错的**局域网地址 —— 表现为「扫码/输入地址后打不开」,且完全无提示。 +- **L2 入口判法漂移风险**:`start.py` 复用、`desktop.py` 拒绝,两套结论对同一端口不一致(`desktop.py:873` 自己就写明「两处判法一旦漂移,桌面端与 `--server-only` 就会对同一端口给出不同结论」)。 + +### 1.2 用户价值 + +- **目标用户**:双击 exe 的桌面用户(单机、单用户);可能同时留着便携版与安装版。 +- **目标场景**:第二次双击 / 升级后忘记退出旧进程 / 8000 被别的软件(IDE、代理、别的本地服务)占着。 +- **不做的代价**:用户看到的是「数据不对」或「版本没生效」—— 这两类症状在本仓历史上都被误判为**缓存没刷**(`docs/agents/ops.md` 第 11 条整节在处理这个误判),排查成本以小时计。 + +--- + +## 2. 用户旅程与核心流程 + +### 2.1 正常路径(≤3 步) + +1. 双击 `DeLector.exe` → 进程启动即取全局锁(**早于**任何数据目录迁移与服务导入)→ 8000 空闲 → 正常出窗。 +2. 再次双击(同一版本、锁被持有)→ 弹窗「DeLector 已在运行(vX.Y.Z,pid N)」→ 退出码 0(**不是** 3,因为它不是失败)。 +3. `python start.py` 走在同一判定上 → 提示 + 打开 `http://127.0.0.1:<实际端口>`。 + +### 2.2 分支路径(每支都必须有人话 + 日志) + +| 条件 | 行为 | 退出码 | +| --- | --- | --- | +| 锁被持有,**版本不同** | 弹窗/文案带双方版本:「旧版本 v5.18.0 仍在运行(pid N),请先退出再启动 v5.19.0」 | 3(桌面)/ 1(start.py) | +| 锁被持有,**数据目录不同** | 同上并列出两个目录(本地弹窗,不进 HTTP) | 3 / 1 | +| 锁被持有,读不出内容(空/损坏) | 「已有 DeLector 实例在运行(版本未知)」——**不猜、不复用** | 3 / 1 | +| 锁可用,8000 被**无关程序**占 | 静默顺延 `8002–8010`;日志与横幅写明实际端口 | 0 | +| 锁可用,8002–8010 全占 | 人话报错并列占用者提示 | 3 / 1 | +| 锁文件**建不出来**(只读介质/权限) | 记 WARNING,回退到现有端口+身份探测路径(行为等于今天) | 按现状 | + +### 2.3 摩擦点与刻意不做 + +- **刻意不做「把已有窗口提到前台」**:pywebview 的跨进程窗口激活需要 Win32 `FindWindow`/`SetForegroundWindow` 与窗口标题契约,属另一条能力线;本设计的诚实做法是「告诉你在跑,然后退出」。 +- **刻意不做「允许并发实例」**:见 §0 与 §6。 + +--- + +## 3. 架构与数据模型 + +### 3.1 新模块:`delector/core/instance_lock.py` + +**边界**:纯 IO + 平台锁原语;**不得**在模块级 import `database`(同 `data_dir_bootstrap` 的硬约束,见 +`tests/test_desktop_data_dir_bootstrap.py:478-488`),**不得** import FastAPI。 + +```text +@dataclass(frozen=True) +class LockRecord: + pid: int + version: str # APP_VERSION + port: int # 实际监听端口(拿到锁后回写) + data_dir: str # resolve_data_dir(env) 的结果;仅用于本地提示,不出现在任何 HTTP 响应里 + acquired_at: str # ISO8601,仅供人工排障 + +@dataclass(frozen=True) +class LockPaths: + lock: str # /instance.lock —— **只被 flock 锁,永不替换** + meta: str # /instance.json —— 元数据,原子替换 + @staticmethod + def for_platform(env: Mapping[str, str]) -> "LockPaths": ... + """Windows: %LOCALAPPDATA%\DeLector\;POSIX: ~/.local/share/DeLector\。 + **刻意不做** Android / 便携版分支:这条路径与数据目录无关(全局唯一)。""" + +@dataclass +class LockHandle: # 持有期间 MUST 保持锁 fd 打开;release() 幂等 + paths: LockPaths + record: LockRecord + def release(self) -> None: ... + +def try_acquire(paths: LockPaths, record: LockRecord) -> LockHandle | LockRecord + """拿到锁 → LockHandle;被持有 → 返回**对方**的 LockRecord(读不出/读不全则返回 unknown 记录)""" +def write_record(handle: LockHandle, record: LockRecord) -> None + """端口/数据目录确定后回写 meta:临时文件 + os.replace,读者永远看到完整 JSON""" +``` + +> **为什么锁与元数据分两个文件(实现期才发现的硬约束,必须照办)**:POSIX 上 `flock` 锁的是 +> **inode**,不是路径。若对同一个文件 `os.replace(tmp, lock)`,旧 fd 仍锁着已被 unlink 的旧 inode, +> 下一个进程打开新 inode 就能成功加锁 —— **锁静默失效**(正是本设计要消除的那类失败)。 +> 因此 `instance.lock` 只做锁目标、内容可为空且永不替换;元数据单独放 `instance.json` 走原子替换。 +> 代价:读元数据必须容忍「锁被持有但 meta 缺失/半截」⇒ 归入 §4 的 unknown 分支,**不新增**判定路径。 + +**平台锁原语**(先例:vault `vault_transaction.py` 的 `acquire_lock`,同一套三档降级): + +- Windows:`msvcrt.locking(fd.fileno(), msvcrt.LK_NBLCK, 1)`(先 `seek(0)`,锁 1 字节)。 +- POSIX:`fcntl.flock(fd, LOCK_EX | LOCK_NB)`。 +- 两者均不可用:**降级为「仅按锁文件内容判定」** + 写 stderr WARNING,并在日志里明确「本次不是真互斥」。 + **不得静默**(`tools/check_silent_degradation.py` 会抓裸 `except: pass`)。 + +**生命周期**:锁随 fd 关闭由 OS 释放 ⇒ 崩溃自愈;**不做 pid 探活启发式**(pid 复用与「刚死未回收」都会骗人)。 +正常退出在 `coordinated_shutdown` 之后显式 `release()`(幂等,只为让下一个实例的提示更干净)。 + +### 3.2 启动编排(单一判定点) + +新增 `start.claim_instance(env) -> Decision`,`desktop.py` 与 `start.py` **共用**(沿用 `desktop.py:887` +「惰性 import `start`,不另写一套」的既有做法): + +```text +Decision = Ready(port: int, lock: LockHandle) | AlreadyRunning(record: LockRecord, reason: str) +``` + +顺序(**每一步都有不变量**): + +1. `resolve_data_dir(env)` —— 纯函数,无副作用(`data_dir_bootstrap.py:118`),只用于填充提示字段。 +2. `try_acquire(LockPaths.for_platform(env), record)` —— **必须先于** `bootstrap_data_dir`:迁移会移动文件, + 并发迁移是当前未设防的窗口。 +3. 未拿到锁 ⇒ 按 §2.2 分支返回 `AlreadyRunning`,调用方负责弹窗/文案后退出(**不再调 `open_browser` 前先判版本**)。 +4. 拿到锁 ⇒ 选端口:`--port` 显式给定时只用它(占用即报错,**不顺延** —— 显式意图优先); + 否则从 `8000` 起、跳过 `8001`、止于 `8010` 取第一个 `is_port_in_use()` 为假者。 +5. `bootstrap_data_dir(env)` → `write_record(handle, 最终 record)`(数据目录可能因迁移失败回退)。 +6. 端口经 `app.state.server_port` 暴露给路由(见 §3.3)。 + +> **对既有守卫的影响(必须显式登记)**:`tests/test_desktop_data_dir_bootstrap.py:402-409` 的 +> `_is_delector_import` 只允许 `delector.core.data_dir_bootstrap` 出现在 `bootstrap_data_dir` **之前**。 +> 本设计需要在它之前 import `delector.core.instance_lock` ⇒ **白名单加一项**,并补一条同款窄守卫 +> `test_instance_lock_module_does_not_import_database`(证明它不会冻结 `DATA_DIR`)。 +> 这是**前提更新**,不是放宽:原断言「bootstrap 早于 `delector.server`」一字不动。 + +### 3.3 `lan-info` 真端口(L1 修复) + +`delector/routes/backup.py:179` 的 `"port": 8000` 改为读 `getattr(request.app.state, "server_port", 8000)`: +- 装配期写入:`start.py` / `desktop.py` 在 `build_server` 之后 `app.state.server_port = port`。 +- 默认 8000 保留给 `TestClient`(测试不设 state 时行为不变)、Android(固定 8000)与旧装配路径。 +- **绝不用 `request.url.port`**:它来自 `Host` 头,可被伪造,而这条响应是给手机端拼地址用的。 + +### 3.4 集成点与明确边界 + +- **Go supervisor 不受本锁保护**:`agent/internal/pythonsvc/supervisor.go:34` 直接 + `python -m uvicorn delector.server:app`(不走 `start.py`),端口固定 8001。它与用户直启实例 + **仍可同时打开同一个库**(SQLite WAL 允许)。本设计**不覆盖**该路径,理由见 §6。 +- **Android**:`bootstrap_data_dir` 返回空串(`data_dir_bootstrap.py:258-259`)⇒ `claim_instance` 直接返回 + `Ready(8000)`,不取锁、不改行为。 + +--- + +## 4. 边界情况与韧性 + +| 场景 | 设计响应 | 依据 | +| --- | --- | --- | +| 进程被任务管理器杀掉 | OS 释放锁,锁文件残留但**可被重新获取**(内容会在下次写入时被覆盖) | `flock`/`LockFile` 语义;不依赖内容判断存活 | +| 锁文件内容半截/非 JSON | 拿到锁的条件下**直接覆盖**(内容只是元数据);拿不到锁时返回 unknown 记录并给通用文案 | 与「不复用未知身份」一致 | +| 两个进程**同时**首启 | 只有一个能拿到 OS 锁 ⇒ 失败方按 AlreadyRunning 处理;**不留 TOCTOU** | 锁在端口探测之前 | +| 端口探测与 bind 之间被抢 | 起服失败由既有 `await_ready` 总截止 + splash 失败路径接住(`desktop.py` Task 3),**不新增**重试逻辑 | 避免与 ADR-0018 Unknown 8 的探测改造撞车 | +| `--port` 显式指定且被占 | 直接报错,不顺延(显式意图优先,避免「我让你开 9000 你却开了 9001」) | 最小惊讶 | +| 锁目录不可写 | WARNING + 回退现状(端口+身份探测),**不阻断启动** | 启动失败比降级更糟 | +| 便携版 + 安装版同时想跑 | 第二个被明确拒绝,并说明对方版本与数据目录 | 全局单实例(§0) | + +--- + +## 5. 测试策略 + +**单元(新文件 `tests/test_instance_lock.py`)** +1. 同进程二次 `try_acquire` 返回对方 record(两个独立 fd)。 +2. `release()` 后同进程可再取;`release()` 幂等。 +3. 内容损坏/空文件:被持有时返回 unknown;可获取时被覆盖且写后内容可解析。 +4. 写记录用 `os.replace` 写 `instance.json`:并发读只会看到完整 JSON(要么旧、要么新); + **且 `instance.lock` 的 inode/句柄在回写后不变**(防「替换后锁失效」回归哨兵)。 +5. `LockPaths.for_platform` 在 Windows/POSIX 两种 env 下的字面值(用 `monkeypatch` 注入 `LOCALAPPDATA`/`HOME`)。 + +**跨进程(一条真子进程用例,`tests/test_instance_lock_subprocess.py`)** +6. 子进程持有锁 → 父进程 `try_acquire` 拿不到,且 record 的 pid/version 与子进程一致;子进程退出后父进程可取到。 + —— 现有 `test_desktop_*` 全是 `monkeypatch`,**盖不住真锁语义**(这是本设计唯一的不可替代用例)。 + +**编排(扩 `tests/test_desktop_port_guard.py` / `tests/test_start.py`)** +7. 版本不同 ⇒ 拒绝且文案含双方版本;版本相同 ⇒ 复用分支(`start.py`)/ 拒绝分支(`desktop.py`,行为不变)。 +8. 8000 被无关程序占、8002 空 ⇒ `Decision.port == 8002`;`8001` **永不**被选中(回归哨兵)。 +9. `--port 9000` 被占 ⇒ 报错,不落到 9001。 +10. 源码顺序守卫:`claim_instance` 在 `main()` 内、早于 `from delector.server import app`(扩 `:431` 那条用例)。 + +**接口(扩 `tests/test_server_*.py`)** +11. `app.state.server_port = 8007` ⇒ `/api/wb/lan-info` 返回 8007;未设 ⇒ 8000(兼容 TestClient)。 + +**验证命令**(逐字复刻 CI 口径,不全绿不算完成) +```bash +python -m pytest tests/test_instance_lock.py tests/test_instance_lock_subprocess.py \ + tests/test_desktop_port_guard.py tests/test_start.py tests/test_desktop_data_dir_bootstrap.py -q +python -m pytest tests/ -q # 单进程串行;本机并行会争数据目录守卫造成假红 +ruff check . +python -m mypy --strict delector tools +python -m mypy --follow-imports=skip tests +python tools/check_layer_invariants.py && python tools/check_silent_degradation.py +``` + +--- + +## 6. 未决项与范围边界 + +> 本节的每一项都**已收敛为决定**:要么做,要么明确不做并写清理由。 + +**已解决的技术未知(实测收敛,不留到实现期)** + +| 未知 | 结论 | 证据 | +| --- | --- | --- | +| 锁放数据目录还是全局? | 全局 | ADR-0019 §6.4;本节末「否决项」 | +| 顺延起点是 8001 吗? | 从 8002 起 | `agent/internal/pythonsvc/doc.go:9-10` 已把 8001 定为 supervisor 保留端口 | +| 身份探测还留吗? | 留,降为锁不可用时的第二腿 | §3.2 | +| 锁内容能进 `/api/health` 吗? | **不能**(`data_dir` 绝不出 HTTP) | 红线⑦同类:LAN 可读端点不得泄露本地路径 | +| pid 探活吗? | 不 | 自愈交给 OS 锁语义 | + +**明确不做(Out of Scope)** + +1. **不做 per-data-dir 多实例**:允许便携版与安装版并存会带来两套窗口/托盘/端口,而真实需求未验证(YAGNI)。 + 要做的话,锁路径改为数据目录、并补「两实例共享 `lan-address.txt`」的处置 —— 属另一次立项。 +2. **不给 Go supervisor 加锁**:它按设计常驻 8001 并管理 Python 子进程,与用户直启实例共享库是**既有**行为; + 改它会牵动 supervisor 的崩溃退避与预算闸,超出本次范围。 +3. **不做窗口激活/单例转发**(见 §2.3)。 +4. **不动 `is_port_in_use` 的 ~2s 探测成本**:那是 ADR-0018 Unknown 8 的独立议题。本设计**不承诺**冷启动提速, + 即便快路径上少了一次身份探测。 +5. **不改 `probe_identity` 的契约**(接受 503、只比 `app`):它是既有守卫,收紧会波及 `await_ready` 与桌面壳。 + +--- + +## Self-Review Checklist + +- [x] 零占位符:§6 的五个未知全部收敛为决定或明确不做。 +- [x] 无隐藏范围蔓延:只碰启动编排、锁模块、`lan-info` 一处端口来源;不动 supervisor / Android / 探测成本。 +- [x] Ladder of Reuse:平台锁用 stdlib(`msvcrt`/`fcntl`),先例为 vault `vault_transaction.py`;不引第三方依赖。 +- [x] 与既有机械闸相容:不新增裸 `except`(`check_silent_degradation`)、模块级不 import `database`(`test_desktop_data_dir_bootstrap.py:478`)。 +- [x] 三处推翻初判均已标注(§0),避免实现者照着对话里的旧结论写。 diff --git a/start.py b/start.py index d90a1c6..36c16c1 100644 --- a/start.py +++ b/start.py @@ -5,6 +5,7 @@ """ import argparse +import logging import os import socket import sys @@ -12,7 +13,9 @@ import time import webbrowser from contextlib import AbstractContextManager, nullcontext -from typing import Callable, Optional +from dataclasses import dataclass, replace +from datetime import datetime +from typing import Callable, Mapping, Optional, Sequence # 为什么必须在最顶部就把 stdout/stderr 重配成 utf-8(本仓既有写法,见 package_windows.py 顶部): # 下面那条启动横幅用 print() 打中文,而**被重定向到文件的 stdout 在 Windows 上默认按 locale/ANSI @@ -36,7 +39,21 @@ # 那一瞬就按"当时的 env"算出 DATA_DIR,之后再设 DELECTOR_DATA_DIR 毫无作用 —— 于是数据 # 仍落在程序目录里,用户"解压新版覆盖旧目录 / 删掉旧目录"= 学习记录全空且零提示 # (ADR-0019 Q2-A / Q3-B)。真正的调用在 `main()` 开头的第一个语句处(见那里的注释)。 -from delector.core.data_dir_bootstrap import bootstrap_data_dir +from delector.core.data_dir_bootstrap import bootstrap_data_dir, resolve_data_dir + +# 单实例锁模块排在 bootstrap 导入**之后**(spec §3.2 要求抢锁先于搬库;这里只借位置表达 +# 「同属启动前哨」)。放模块顶层是安全的:它只吃 stdlib、不在模块级 import `delector.database` +# —— 由 `tests/test_desktop_data_dir_bootstrap.py::test_instance_lock_module_does_not_import_database` +# 钉住,那条用例正是 `_BOOTSTRAP_SAFE_IMPORTS` 放行它的唯一凭据。 +from delector.core.instance_lock import ( + UNKNOWN_RECORD, + LockHandle, + LockPaths, + LockRecord, + read_record, + try_acquire, + write_record, +) def _ensure_entry_streams(debug_console: bool) -> None: @@ -95,6 +112,262 @@ def get_bind_host() -> str: return "127.0.0.1" if is_android() else "0.0.0.0" +# ── 单实例判定(spec 2026-10-21 §3.2)────────────────────────────────────────── +# +# 为什么判定必须收敛到**一个**函数:`main()`、桌面壳(`desktop.py`)与日后的 supervisor 都要问 +# 同一句「这台机器上是不是已经有一个 DeLector 在跑、它是谁」。判据各抄一份就会漂成「壳说没有、 +# 服务说有两个」—— 那类缺陷不报错、只让用户看到两个窗口抢同一个库,且事后无法归因。 + +#: 端口候选。8000 是历史默认;**8001 刻意不在表里** —— 它是 Go supervisor 的保留端口 +#: (`agent/internal/pythonsvc/doc.go:9-10`),抢到它等于和 supervisor 争同一个监听点。 +PORT_CANDIDATES: tuple[int, ...] = (8000, 8002, 8003, 8004, 8005, 8006, 8007, 8008, 8009, 8010) + +#: 全局硬约束「8001 是 Go supervisor 的保留端口,MUST 永不选中」的实现点。仅靠 `PORT_CANDIDATES` +#: 不含它只挡住了**自动顺延**;显式 `requested_port=8001`(壳 / supervisor / 用户传参)照样会被 +#: 直接返回 —— 那就是和 supervisor 争同一个监听点,两个进程里必有一个起不来且原因看日志都像 +#: 「端口神秘被占」。故显式值也要过这道闸。 +SUPERVISOR_RESERVED_PORT: int = 8001 + + +@dataclass(frozen=True) +class Ready: + """本进程即那个实例:`port` 已选定、`lock` 已持有(调用方退出/失败时 MUST `lock.release()`)。""" + + port: int + lock: LockHandle + + +@dataclass(frozen=True) +class AlreadyRunning: + """已有实例在跑(或端口被占):`reason` 说明凭什么这么判,`record` 是对方身份。 + + ⚠️ 上层 MUST 按 `reason` 分派,MUST NOT 按 `record` 分派:`no_free_port` / `port_busy` + 没有可归因的对方实例,`record` 只能复用 `UNKNOWN_RECORD`(pid=0/port=0 的占位值)—— + 拿它当「对方是谁」去读,会把「端口全占」讲成「有个身份不明的实例在跑」。 + """ + + record: LockRecord + reason: str + + +def _held_reason(record: LockRecord, data_dir: str) -> str: + """锁被持有时归因,顺序即优先级:`unknown` > `version_mismatch` > `data_dir_mismatch` > `same`。 + + 为什么 `unknown` 排最前:`pid == 0` 是 `UNKNOWN_RECORD` 的形态(meta 缺失 / 半截 / 读不动)。 + 此时 `version` 与 `data_dir` 都是占位值,拿它们继续比会得出「版本不同」这种**假结论**, + 上层照着提示「请升级对方」——用户升级完依旧起不来,而他手里的两个解释都是错的。 + + `port <= 0` 也是同一档:抢到锁到回写端口之间有一个**合法的中间态**(`claim_instance` 先以 + `port=0` 占位抢锁,选定端口后才回写)。别人恰好读到这一刻,pid / 版本 / 目录全都对得上, + 但端口还是占位的 `0` —— 若照 `same` 归因,上层会拼出 `http://127.0.0.1:0` 这种指不到任何 + 东西的地址。故端口未回写一律视为「身份未知」,宁可让用户重试,也不给假地址。 + + ⚠️ 这一档的**常态成因是对方正在启动**(它要逐个探 10 个候选端口,最坏 ~20s),而不是 + 「上一个死进程留下的残骸」。故上层文案 MUST NOT 断言「残留进程」—— 那会让用户去杀掉一个 + 完全健康的实例(见 `_report_already_running` 的兜底分支)。 + """ + if record.pid == 0 or record.port <= 0: + return "unknown" + if record.version != _app_version(): + return "version_mismatch" + # 规范化后再比:`D:\DeLector\data` 与 `d:\delector\data\` 是同一个目录。纯字符串比较会把 + # 「同一份库」误判成 `data_dir_mismatch`,上层于是提示「另一份库的实例在跑」—— 用户照做 + # 只会关掉本该复用的那一个。`normcase` 在 POSIX 上是恒等,故不影响大小写敏感平台。 + if os.path.normcase(os.path.normpath(record.data_dir)) != os.path.normcase(os.path.normpath(data_dir)): + return "data_dir_mismatch" + return "same" + + +def _pick_port( + probe: Callable[[int], bool], + requested: Optional[int], + busy_sink: Optional[list[int]] = None, +) -> Optional[int]: + """选定端口;无可用端口返回 `None`。 + + 显式 `requested` **只试它、不顺延**:调用方(壳 / supervisor / 用户)已经对外承诺了那个 + 地址(地址文件、反代、hosts),偷偷换一个端口等于让承诺的地址永远连不上;宁可报 + `port_busy` 让上层去决定。反之遍历候选时必须顺延,否则「8000 被别的程序占」就直接起不来。 + + `busy_sink`:把**探测为被占用**的端口按序收集起来,供身份探测(第二腿)复用。为什么不另写 + 一个循环再探一次:`is_port_in_use` 单次真实成本 ~2s(ADR-0018 Unknown 8),最坏情形下重探 + 一遍等于把启动时延翻倍;端口占用状态本身就是这次探测的结果,没有第二次获取的必要。 + """ + if requested is not None: + # 显式指定也 MUST 过保留端口闸:8001 是 Go supervisor 的监听点(见 `SUPERVISOR_RESERVED_PORT`), + # 抢到它等于和 supervisor 争同一个端口。按「不可用」处理,让上层落 `port_busy` —— 那正是 + # 事实:这个端口对本进程永远不可用,与此刻有没有人在听无关,故**不调 probe**。 + if requested == SUPERVISOR_RESERVED_PORT: + return None + if not probe(requested): + return requested + if busy_sink is not None: + busy_sink.append(requested) + return None + for port in PORT_CANDIDATES: + if not probe(port): + return port + if busy_sink is not None: + busy_sink.append(port) + return None + + +def _note(message: str) -> None: + """启动期提示:stderr 可用就照旧(与既有风格一致),被收口成 None 时退到 logger,绝不静默。 + + 为什么不能只 `print(..., file=sys.stderr)`:`--windowed`/`pythonw` 桌面壳下 `sys.stderr` + 是 **None**,CPython 对 `file=None` 的 `print` 是**直接丢弃**(不报错、不落任何地方)—— + 于是「端口被占用改用了别的端口」「元数据回写失败」这类只能靠日志归因的提示在桌面端 + 100% 消失,用户只看到地址变了却零线索。 + """ + if sys.stderr is not None: + print(message, file=sys.stderr) + return + logging.getLogger("delector").warning(message) + + +def _android_ready(env: Mapping[str, str]) -> Ready: + """Android 的短路结果:不取锁、不探端口。 + + 两个理由:① Android 的数据落点由宿主(`MainActivity`)注入 `DELECTOR_DATA_DIR`,本进程 + 替它猜一个锁目录只会造出一把与真实实例无关的锁;② 监听口由 WebView 侧约定,候选顺延在 + 移动端没有意义。返回的句柄是**降级句柄**(`_fd == -1`,`release()` 是幂等空操作), + 这样调用方仍可统一写 `lock.release()`,不必为移动端分支记第二套约定。 + """ + port = PORT_CANDIDATES[0] + record = LockRecord( + pid=os.getpid(), + version=_app_version(), + port=port, + data_dir=resolve_data_dir(env), + # 这条记录不会被写盘(没有锁文件),时间戳留空以避免伪造「何时抢到锁」的痕迹。 + acquired_at="", + ) + return Ready(port=port, lock=LockHandle(paths=LockPaths.for_platform(env), record=record)) + + +def _identity_holder( + handle: LockHandle, + data_dir: str, + busy: Sequence[int], + identity_probe: Callable[[int], bool], +) -> Optional[AlreadyRunning]: + """第二腿:锁降级时,在**被占的候选端口**上找 DeLector 身份;找到就返回 `AlreadyRunning`。 + + 为什么必须有这一腿:锁降级的成因(目录只读 / 权限不足 / 无锁原语)恰好也是「另一个 + DeLector 同样抢不到锁」的成因。此时「候选端口被占」≠「陌生程序」——若直接顺延,就会 + 起**第二个服务**、两个进程改同一个库,正是本计划要消灭的缺陷。 + + 为什么"找到"不一定是 `same`:端口上是 DeLector,但它可能来自另一个版本 / 另一份库 + (meta 里写着自己是谁)。归因仍走 `_held_reason`(同一套优先级,不接受第二套口径); + 唯一例外是 meta 读不出(`UNKNOWN_RECORD`,pid==0/port==0 的占位值)——**线上已经探到 + DeLector 身份**,此时再按占位值讲「版本不同」就是假话,故收敛为 `same`。 + """ + for port in busy: + if not identity_probe(port): + continue + # meta 可能是别人(或上一次运行)留下的:读得出就带上版本/目录,读不出就是占位记录; + # 两种情况下端口都以**探到的**为准 —— 那是唯一可验证的事实。 + record = replace(read_record(handle.paths) or UNKNOWN_RECORD, port=port) + reason = _held_reason(record, data_dir) + return AlreadyRunning(record=record, reason="same" if reason == "unknown" else reason) + return None + + +def claim_instance( + env: Mapping[str, str], + requested_port: Optional[int] = None, + probe: Optional[Callable[[int], bool]] = None, + identity_probe: Optional[Callable[[int], bool]] = None, +) -> Ready | AlreadyRunning: + """单实例判定的唯一入口:先抢锁、再选端口。 + + 顺序为什么不能反(计划 Task 4 GREEN 的硬要求):先探端口再抢锁,等于在「另一个实例正在 + 监听」的噪声里判断可用性 —— 顺延时会把对方实例的端口当成「被陌生人占用」,报错话术与 + 真实原因不符;更糟的是探测与抢锁之间的窗口里可能又冒出第三个进程。 + + `identity_probe`(可选):锁**降级**时(`LockHandle.is_degraded`)的第二腿 —— 此时锁没有权威, + 「候选端口被占」既可能是陌生程序、也可能是另一个同样抢不到锁的 DeLector。对**被跳过的** + 端口逐个问它,答是 ⇒ 判 `AlreadyRunning`(绝不顺延起第二个服务)。拿到**真锁**时一律不调: + 锁是唯一判据(见 `tests/test_start.py::test_claim_instance_skips_identity_probe_when_lock_is_real`)。 + """ + if is_android(): + return _android_ready(env) + + # `probe` 必须在**函数体内**求值:写成默认参数 `probe=is_port_in_use` 会在 import 期把当时的 + # 函数对象焊死,测试 monkeypatch `start.is_port_in_use` 就完全失效(判据看着在跑、实际恒真)。 + probe_fn: Callable[[int], bool] = probe or is_port_in_use + # 落点先解析:它既是判据(和对方的 data_dir 比),也要写进自己的记录供下一个进程读。 + data_dir = resolve_data_dir(env) + paths = LockPaths.for_platform(env) + + # 端口此刻还不知道 ⇒ 先以 `port=0` 抢锁(记录里 0 只表示「还没定」),选定端口后回写。 + acquired = try_acquire( + paths, + LockRecord( + pid=os.getpid(), + version=_app_version(), + port=0, + data_dir=data_dir, + acquired_at=datetime.now().astimezone().isoformat(timespec="seconds"), + ), + ) + if isinstance(acquired, LockRecord): + # 被持有 ⇒ 读到的就是对方身份(读不出时 `try_acquire` 已收敛成 `UNKNOWN_RECORD`)。 + return AlreadyRunning(record=acquired, reason=_held_reason(acquired, data_dir)) + + handle = acquired + # 从这里到 `return Ready(...)` 之间的每一步都可能抛:`_pick_port` 的 `probe` 是外部注入的 + # (生产实现 `is_port_in_use` 在 fd 耗尽时会抛 `OSError`),`write_record` 也可能是 + # `TypeError`/`ValueError` 之外的异常。异常穿出而不释放 fd ⇒ 锁被一个已经失败的进程 + # 攥着 ⇒ 同进程后续 claim(以及用户重开)全被判「已在运行」的**假红**。 + # `BaseException` 而非 `Exception`:`KeyboardInterrupt` 同样必须放锁,否则 Ctrl+C 之后 + # 进程还没退出就再也起不来了。 + # 句柄是否**降级**(`is_degraded`:目录建不出来 / 文件打不开 / 无锁原语)决定了「端口被占」该怎么读: + # 真锁在手 ⇒ 锁是唯一判据,被占的端口一定是别人的(**不**调身份探测,避免多一次 HTTP 往返, + # 也避免让陌生程序有机会冒充自己);降级 ⇒ 锁没有权威,必须补上第二腿(见 `_identity_holder`)。 + # 读 `LockHandle.is_degraded` 而不是跨模块点 `handle._fd`:`_fd` 是 instance_lock 的私实现细节 + # (将来换成 HANDLE/句柄对象就变),降级这件事必须由那个模块自己回答。 + degraded = handle.is_degraded + identity_fn: Optional[Callable[[int], bool]] = identity_probe if degraded else None + busy: list[int] = [] + try: + port = _pick_port(probe_fn, requested_port, busy if identity_fn is not None else None) + # 身份判定 MUST 早于「用上刚选到的空闲端口」:降级锁下 8000 上的 DeLector 只是抢不到锁, + # 顺延到 8002 就会变成两个服务同时改一个库 —— 那正是本计划要消灭的缺陷。故先看被跳过的 + # 那些端口上是不是自己人,是则整体放弃启动(而不是"占个别的口先跑起来")。 + holder = None if identity_fn is None else _identity_holder(handle, data_dir, busy, identity_fn) + if holder is not None: + handle.release() + return holder + if port is None: + # 早退**必须**先释放锁:残留的锁会让下一次启动(含同进程的下一条用例)被判成 + # 「已有实例在运行」—— 症状是本进程明明退出了,用户每次重开都被告知"已在运行"。 + handle.release() + reason = "port_busy" if requested_port is not None else "no_free_port" + return AlreadyRunning(record=UNKNOWN_RECORD, reason=reason) + + if requested_port is None and port != PORT_CANDIDATES[0]: + # No Silent Fallback:默认端口被别的程序占走时,用户看到的地址会变 —— 必须自己说出来, + # 否则「为什么我在 8000 打不开」只能靠猜。 + _note(f"[提示] 端口 {PORT_CANDIDATES[0]} 被占用,改用 {port}。") + + record = replace(handle.record, port=port) + try: + write_record(handle, record) + except OSError as exc: + # 回写失败只影响「别人看到哪个端口」,不影响互斥、也不影响本进程将要监听的那个端口 + # ⇒ 留痕后继续(与 instance_lock.try_acquire 的降级口径一致),不把启动挡在门外。 + _note(f"[警告] 单实例元数据回写失败({exc}):其他实例可能读到旧端口。") + # 句柄里的 record 也要跟上:`Ready.lock.record.port` 是调用方唯一能读到的"我占了哪个口"。 + handle.record = record + return Ready(port=port, lock=handle) + except BaseException: + # `release()` 幂等,故与上面 `port is None` 分支的显式释放不冲突。 + handle.release() + raise + + def open_browser(port: int) -> None: time.sleep(1.2) # Support Android Termux termux-open-url fallback @@ -130,74 +403,215 @@ def print_banner(port: int, android: bool) -> None: print(" 按 Ctrl+C 停止服务\n") +def _say(message: str) -> None: + """面向用户的启动文案:stdout 可用就照旧,被收口成 None(`pythonw`/窗口化)时退到 logger。 + + 与 `_note` 的差别只在**默认落点**:`_note` 面向排障(stderr / 日志),`_say` 面向用户 + (正常输出)。两者都必须对 `sys.stdout is None` 免疫 —— 否则桌面端「已在运行」这类 + 唯一线索会被 `print(..., file=None)` 静默丢弃。 + """ + if sys.stdout is not None: + print(message) + return + logging.getLogger("delector").warning(message) + + +def _fail(message: str) -> None: + """启动失败:正常控制台下**只**写 stdout,然后退出码 1。 + + 为什么不再同时写 stdout 与 stderr:正常控制台里两条流都指向同一个屏幕 ⇒ 用户会看到同一行 + 错误**两遍**(第一遍是噪音,第二遍让人以为是两个不同的错)。排障需要的是「被收口」时仍在—— + `sys.stdout` 为 None(`pythonw` / `--windowed` 桌面壳)才退到 `_note`(stderr / 日志)那条路。 + 同一个字符串,不另拟文案;两路都不会静默丢弃,故「为什么没起来」始终留痕。 + """ + if sys.stdout is not None: + print(message) + else: + _note(message) + sys.exit(1) + + +def _describe_holder(record: LockRecord) -> str: + """把对方身份拼成一段人话(只列**已知**字段,绝不给占位值)。""" + parts: list[str] = [] + if record.version and record.version != UNKNOWN_RECORD.version: + parts.append(f"v{record.version}") + if record.pid > 0: + parts.append(f"pid {record.pid}") + # `port <= 0` 是「端口还没回写」的占位值:写出来会得到 `端口 0` 这种指不到任何东西的地址。 + if record.port > 0: + parts.append(f"端口 {record.port}") + return ",".join(parts) + + +def _report_already_running(decision: AlreadyRunning, android: bool) -> None: + """`AlreadyRunning` 的分派点:**按 `reason` 分派**(`record` 只用来读身份字段)。 + + 按 `record` 分派会出错:`port_busy` / `no_free_port` 根本没有可归因的对方实例,`record` + 只能是 `UNKNOWN_RECORD`(pid=0/port=0 的占位值)—— 拿它当「对方是谁」,就会把「端口全被 + 别的软件占着」讲成「有个身份不明的 DeLector 在跑」,用户于是去关一个并不存在的窗口。 + + 三种结局: + - `same` ⇒ 人话 + (非 Android)**先复核**对方端口确实响应 `/api/health` 再打开页面, + **正常返回**(退出码 0:用户看到的就是既有实例)。 + - `port_busy` / `no_free_port` ⇒ 说「端口不可用」并退出 1。**刻意不提**「已在运行」: + 那不是同一件事(见上)。 + - 其余(`unknown` / `version_mismatch` / `data_dir_mismatch`)⇒ 说明差异后退出 1: + 用户要做的是关掉/升级旧实例,盲目顺延只会得到两个进程改同一个库。 + """ + record = decision.record + reason = decision.reason + if reason == "same": + detail = _describe_holder(record) + # 不变式:**绝不为一个不响应 `/api/health` 的端口打开页面**(ADR-0020 §6.4)。 + # `record` 可能是上一次运行留下的陈旧 meta(`write_record` 失败只 WARNING ⇒ 端口仍是旧值), + # 此时 `record.port` 上未必真有一个 DeLector;照旧开页面等于把用户扔到一个打不开的地址, + # 且用户无法判断"是不是已经在跑"。故开页面之前用 `_identity_probe` 复核一次。 + # 退出码仍为 0(正常返回,不 `sys.exit`):对方确实存在且持锁(`same` 的判据是锁,不是端口), + # 端口无响应只是**暂态**(它可能正在逐个探候选端口,最坏 ~20s)—— 以 1 结束会让桌面壳 / + # supervisor 把"本该复用的实例"当成启动失败并反复重启,反过来对健康实例造成启动风暴。 + if not android and not _identity_probe(record.port): + _say( + f"[提示] DeLector 已在运行({detail}),但该端口无响应" + "(可能正在启动或已退出)。请稍等几秒后重试。" + ) + return + _say(f"[提示] DeLector 已在运行({detail}),正在打开已有页面...") + if not android: + open_browser(record.port) + return + if reason == "port_busy": + _fail("[错误] 端口不可用:指定的端口已被其他程序占用,无法启动 DeLector。") + return + if reason == "no_free_port": + first, last = PORT_CANDIDATES[0], PORT_CANDIDATES[-1] + _fail(f"[错误] 端口不可用:{first}–{last} 全部被其他程序占用,无法启动 DeLector。请关闭占用端口的程序后重试。") + return + if reason == "version_mismatch": + _fail( + f"[错误] 这台机器上已有一个 DeLector 在运行,但版本不同" + f"(正在运行 v{record.version},本程序 v{_app_version()})。请关闭它后再启动。" + ) + return + if reason == "data_dir_mismatch": + _fail( + f"[错误] 这台机器上已有一个 DeLector 在运行,但它使用的是另一份数据目录" + f"({record.data_dir})。请关闭它后再启动。" + ) + return + # 兜底落在 `unknown`:锁被持有但身份读不出来(meta 缺失 / 半截 / 端口未回写)。 + # 不猜版本也不猜端口 —— 那两个字段此刻都是占位值,说出来只会误导。 + # 文案 MUST NOT 断言「残留进程」:`port == 0` 的常态成因是对方**正在启动**(逐个探候选端口, + # 最坏 ~20s)—— 照「残留」讲,用户会去杀掉一个健康实例,本进程照样起不来(锁仍被持有)。 + _fail( + "[错误] 这台机器上已有一个 DeLector 在运行,但读不到它的版本与端口信息" + "(实例信息缺失或损坏)。若刚刚双击过图标,请稍等几秒再试;" + "若持续如此,请先关闭正在运行的 DeLector 再重新启动。" + ) + + def main(debug_console: bool = False, on_serving: Optional[Callable[[int], None]] = None) -> None: _ensure_entry_streams(debug_console) - port = 8000 android = is_android() - if is_port_in_use(port): - # 不再盲复用:先分辨占用者是不是 DeLector 自己(ADR-0020 §6.4),否则会为 - # 一个无关程序打开页面。判定与动作封装在 _handle_existing_port(惰性 import)。 - _handle_existing_port(port, android) + # 单一判定点(spec §3.2):抢锁 → 选端口 → 归因。`identity_probe` 只在**锁降级**时被用到 + # (见 `claim_instance` 的 `degraded` 分支):真锁在手时锁是唯一判据,不额外发 HTTP 探测。 + decision = claim_instance(os.environ, identity_probe=_identity_probe) + if isinstance(decision, AlreadyRunning): + # 已有实例 / 端口不可用 / 身份存疑:这里 MUST 早退,且**早于** bootstrap_data_dir —— + # 别人的库不能动(Windows 上改名会被占用挡住),`delector.server` 也不必 import。 + _report_already_running(decision, android) return - # ⚠️ 必须在 `from delector.server import app` **之前**执行:那一行一跑,`database` - # 已按仓库根把 DATA_DIR 落定,此时再设 DELECTOR_DATA_DIR 就晚了。 - # 放在端口占用早退**之后**是刻意的:已有实例在跑时不动它的库(Windows 上改名会被占用挡住)。 - bootstrap_data_dir(os.environ) + ready = decision + # 端口从此只来自 `ready.port`:横幅、浏览器、build_server、on_serving 全部用它。 + port = ready.port + # `finally` 放锁是**硬要求**(不是"顺手清理"):锁是文件级的,同进程里反复调 `main()` 的测试 + # 若漏放,后面的用例会被判「已在运行」而集体假红;真实用户侧则表现为 Ctrl+C 之后再也起不来 + # (进程还在退出中,锁仍被自己的 fd 攥着)。 + try: + # ⚠️ 必须在 `from delector.server import app` **之前**执行:那一行一跑,`database` + # 已按仓库根把 DATA_DIR 落定,此时再设 DELECTOR_DATA_DIR 就晚了。 + bootstrap_data_dir(os.environ) - host = get_bind_host() - print_banner(port, android) + host = get_bind_host() + print_banner(port, android) - if not android: - threading.Thread(target=open_browser, args=(port,), daemon=True).start() + if not android: + threading.Thread(target=open_browser, args=(port,), daemon=True).start() + + from delector.core.server_lifecycle import SHUTDOWN_GRACE_S, build_server, serve_in_thread, shutdown + from delector.server import app + + server = build_server(host, port, app) + # Task 5:端口校验已过(由 `claim_instance` 选定,被占的端口根本不会被返回)、 + # `uvicorn.Server` 已组装 ⇒ 调用方(desktop `--server-only`)据此写对外可见的地址文件。 + # 写入 MUST 晚于"确认本进程将持有该端口":端口不可用 / 启动失败时根本到不了这里, + # 故绝不留下指向错误目标的地址。 + if on_serving is not None: + on_serving(port) + try: + # 禁用信号处理:Android Chaquopy 在子线程里跑 server,而 signal.signal 只允许 + # 在 Python 主线程调用。注意 uvicorn 的 API 随版本迁移(0.52 起只有 + # capture_signals,install_signal_handlers 已不存在)——两代都覆盖, + # 否则「防护」会静默失效成一条没人调用的实例属性(mypy 抓到这个失效)。 + if threading.current_thread() is not threading.main_thread(): + # 用 setattr(名字字符串) 覆盖两代 API:按名字赋值绕开静态属性检查(不同 uvicorn + # 版本声明的属性不一样,直接点属性访问会被 mypy 判 attr-defined/method-assign), + # 行为与直接属性赋值逐字一致 —— 不必写 `# type: ignore`(禁新增豁免)。 + setattr(server, "install_signal_handlers", lambda: None) # 旧 uvicorn API + setattr(server, "capture_signals", _noop_signal_context) # 新 uvicorn API + except Exception: + pass + # 非 daemon 线程里跑 server,主线程 join 等待;Ctrl+C 时先收尸再退出 —— 这替代了 + # 原来裸 `server.run()` 的「无退出控制」,消除「壳退了服务还残留」的静默失败。 + thread = serve_in_thread(server) + try: + # 轮询式等待(**每次 join 都带显式超时**,禁裸等):语义仍是「主线程陪 server 到结束」, + # 但单次等待封在 SHUTDOWN_GRACE_S 内(与 shutdown() 的宽限同口径,不另发明数字)—— + # 裸 join() 一旦线程不结束就无上界,整个退出路径会被它吞掉。Ctrl+C 打断的正是这一次 + # join(KeyboardInterrupt 从 join 抛出),仍走下方收尸分支。 + while True: + thread.join(timeout=SHUTDOWN_GRACE_S) + if not thread.is_alive(): + break + except KeyboardInterrupt: + print("\n[提示] 收到停止信号,正在收尸...") + # join 之后再收一次是**有意保留**的兜底:覆盖 KeyboardInterrupt 之外的退出路径。 + # `shutdown` 是幂等的(置 should_exit + join 一个已结束的线程都无副作用),重复调用安全 + # —— 勿当成"重复 shutdown 的 bug"删除。 + if not shutdown(server, thread): + # 尊重返回值(旧代码丢弃它并无条件报「已停止」=假成功):停不干净必须可见 —— 人话进 + # stderr(无控制台时由流收口落 launch.log),退出码 1(干净退出才是 0)。继续静默返回 + # 的后果:解释器退出时 threading._shutdown() 再 join 这个非 daemon 线程 ⇒ 进程挂住、 + # 端口仍占 ⇒ 下次启动会撞上一个连自己都关不掉的实例。 + print("[错误] 服务未能干净退出(端口可能仍被占用)。", file=sys.stderr) + sys.exit(1) + print("[提示] 服务已停止。") + finally: + # `release()` 幂等(降级句柄是空操作 ⇒ Android 分支同样安全)。放在最外层 `finally` + # 而不是 shutdown 之后:`bootstrap_data_dir` / `build_server` / `on_serving` 任一步抛, + # 锁都必须被放开 —— 一个已经失败的进程攥着锁,等于用户每次重开都被告知「已在运行」。 + ready.lock.release() + + +def _identity_probe(port: int) -> bool: + """第二腿:回答「这个端口上是不是 DeLector」(`claim_instance` 的 `identity_probe` 实参)。 + + 只在**锁降级**时才会被调用(见 `claim_instance` 的 `degraded` 分支):真锁在手时锁是唯一 + 判据,不该为陌生程序留一次「冒充自己」的机会,也不该白付一次 HTTP 往返。 + + 惰性 import 的理由与 `_handle_existing_port` 同款:`delector.core.server_lifecycle` 不在 + `_BOOTSTRAP_SAFE_IMPORTS` 白名单里,模块级导入会撞 + `tests/test_desktop_data_dir_bootstrap.py::test_start_py_bootstrap_precedes_every_delector_import` + (该闸钉「bootstrap 调用早于任何 `delector.*` 导入」)。 + """ + from delector.core.server_lifecycle import probe_identity - from delector.core.server_lifecycle import SHUTDOWN_GRACE_S, build_server, serve_in_thread, shutdown - from delector.server import app - - server = build_server(host, port, app) - # Task 5:端口校验已过(is_port_in_use 早退在更上方)、`uvicorn.Server` 已组装 ⇒ 调用方 - # (desktop `--server-only`)据此写对外可见的地址文件。写入 MUST 晚于"确认本进程将持有 - # 该端口":端口被占 / 启动失败时根本到不了这里,故绝不留下指向错误目标的地址。 - if on_serving is not None: - on_serving(port) - try: - # 禁用信号处理:Android Chaquopy 在子线程里跑 server,而 signal.signal 只允许 - # 在 Python 主线程调用。注意 uvicorn 的 API 随版本迁移(0.52 起只有 - # capture_signals,install_signal_handlers 已不存在)——两代都覆盖, - # 否则「防护」会静默失效成一条没人调用的实例属性(mypy 抓到这个失效)。 - if threading.current_thread() is not threading.main_thread(): - # 用 setattr(名字字符串) 覆盖两代 API:按名字赋值绕开静态属性检查(不同 uvicorn - # 版本声明的属性不一样,直接点属性访问会被 mypy 判 attr-defined/method-assign), - # 行为与直接属性赋值逐字一致 —— 不必写 `# type: ignore`(禁新增豁免)。 - setattr(server, "install_signal_handlers", lambda: None) # 旧 uvicorn API - setattr(server, "capture_signals", _noop_signal_context) # 新 uvicorn API - except Exception: - pass - # 非 daemon 线程里跑 server,主线程 join 等待;Ctrl+C 时先收尸再退出 —— 这替代了 - # 原来裸 `server.run()` 的「无退出控制」,消除「壳退了服务还残留」的静默失败。 - thread = serve_in_thread(server) - try: - # 轮询式等待(**每次 join 都带显式超时**,禁裸等):语义仍是「主线程陪 server 到结束」, - # 但单次等待封在 SHUTDOWN_GRACE_S 内(与 shutdown() 的宽限同口径,不另发明数字)—— - # 裸 join() 一旦线程不结束就无上界,整个退出路径会被它吞掉。Ctrl+C 打断的正是这一次 - # join(KeyboardInterrupt 从 join 抛出),仍走下方收尸分支。 - while True: - thread.join(timeout=SHUTDOWN_GRACE_S) - if not thread.is_alive(): - break - except KeyboardInterrupt: - print("\n[提示] 收到停止信号,正在收尸...") - # join 之后再收一次是**有意保留**的兜底:覆盖 KeyboardInterrupt 之外的退出路径。 - # `shutdown` 是幂等的(置 should_exit + join 一个已结束的线程都无副作用),重复调用安全 - # —— 勿当成"重复 shutdown 的 bug"删除。 - if not shutdown(server, thread): - # 尊重返回值(旧代码丢弃它并无条件报「已停止」=假成功):停不干净必须可见 —— 人话进 - # stderr(无控制台时由流收口落 launch.log),退出码 1(干净退出才是 0)。继续静默返回 - # 的后果:解释器退出时 threading._shutdown() 再 join 这个非 daemon 线程 ⇒ 进程挂住、 - # 端口 8000 仍占 ⇒ 下次启动 `_handle_existing_port` 命中旧实例。 - print("[错误] 服务未能干净退出(端口可能仍被占用)。", file=sys.stderr) - sys.exit(1) - print("[提示] 服务已停止。") + # 显式落到 bool 局部变量:入口面 mypy 闸带 `--follow-imports=skip` + # (`.github/workflows/ci.yml`),被跳过的模块符号退化成 `Any` ⇒ 直接 `return` + # 会报 `no-any-return`(本地不带该 flag 时是绿的,故这是**只在 CI 暴露**的那类红)。 + identity: bool = probe_identity(port) + return identity def _handle_existing_port(port: int, android: bool) -> None: @@ -207,6 +621,11 @@ def _handle_existing_port(port: int, android: bool) -> None: 浏览器指向已有实例,然后返回(不重复起服务)。 - 不是 DeLector ⇒ 明确报错并退出,**绝不**为无关程序打开页面(ADR-0020 §6.4)。 + **当前无生产调用者**:`main()` 自 Task 6 起按 `AlreadyRunning.reason` 分派(见 + `_report_already_running`),桌面壳走自己的 `_check_port_or_fail`(`desktop.py:873-921`), + 故本函数唯一引用者是 `tests/test_desktop_shell.py`。Task 7 把桌面壳改用 `claim_instance` + 后应删除此函数或改薄包装 —— 现在保留只是暂不删,避免提前改动桌面端行为。 + 这里用**惰性 import** 而非文件顶部导入:本函数定义在 main() 之后,其 import 语句 的行号才大于 main() 里 `bootstrap_data_dir(...)` 的调用行号 —— 否则会撞上 tests/test_desktop_data_dir_bootstrap.py::test_start_py_bootstrap_precedes_every_delector_import @@ -224,6 +643,22 @@ def _handle_existing_port(port: int, android: bool) -> None: sys.exit(1) +def _app_version() -> str: + """取应用版本号(`claim_instance` 判「对方版本是否不同」的唯一来源)。 + + 这个函数**必须定义在 `main()` 之后**,理由与 `_handle_existing_port` 的惰性 import 同款: + `tests/test_desktop_data_dir_bootstrap.py::test_start_py_bootstrap_precedes_every_delector_import` + 按**全模块最小行号**判「bootstrap 调用早于任何 `delector.*` 导入」,而 `delector.core.version` + 不在 `_BOOTSTRAP_SAFE_IMPORTS` 白名单里 ⇒ 这段 import 一旦落在 `main()` 之前,守卫就转红。 + `version` 是纯 stdlib 叶模块,惰性导入零成本,也不会碰 `database`。 + """ + from delector.core.version import APP_VERSION + + # 同 `_identity_probe`:`--follow-imports=skip` 下 `APP_VERSION` 是 `Any`,必须显式收窄。 + version: str = APP_VERSION + return version + + def _parse_args(argv: list[str]) -> argparse.Namespace: """解析跨平台入口的调试控制台运行时开关。""" parser = argparse.ArgumentParser(prog="start.py", description="DeLector 跨平台启动器。") diff --git a/tests/server_helpers.py b/tests/server_helpers.py index cd8abfa..8421477 100644 --- a/tests/server_helpers.py +++ b/tests/server_helpers.py @@ -257,6 +257,28 @@ def _calls(node: ast.AST) -> Iterator[ast.Call]: yield child +def stmts_through_try(body: List[ast.stmt]) -> Iterator[ast.stmt]: + """把 `try` / `finally` 当**透明容器**展开,只透出真正的执行语句。 + + 两个"为什么"(Task 7 首次踩到,两条都会让守卫静默失效): + 1. **必须展开**:`desktop.py::_run_desktop_inner` 自 Task 7 起用 `try/finally` 包住主体 + (放锁),原本在函数顶层被锚定的语句下沉了一层(`_show_window` 还在内层 try 里)—— + 不展开就"找不到锚点/少一枚标记"。 + 2. **必须跳过容器自身**:`_calls` 走整棵子树,容器会把任何被它包住的调用算成自己命中 ⇒ + 锚点退化成"整个 try 块",夹逼/归属断言随之失去判别力。 + 刻意**不**递归进 `if` / `for`:那会让锚点漂到条件分支里的无关语句上。 + """ + for stmt in body: + if isinstance(stmt, ast.Try): + yield from stmts_through_try(stmt.body) + yield from stmts_through_try(stmt.orelse) + yield from stmts_through_try(stmt.finalbody) + for handler in stmt.handlers: + yield from stmts_through_try(handler.body) + continue + yield stmt + + def _call_label(call: ast.Call) -> Optional[str]: """调用的"可读名":`a.b()` → attr `b`;`f()` → id `f`;其余返回 None。""" func = call.func diff --git a/tests/test_desktop_data_dir_bootstrap.py b/tests/test_desktop_data_dir_bootstrap.py index c9fc4d9..224fd85 100644 --- a/tests/test_desktop_data_dir_bootstrap.py +++ b/tests/test_desktop_data_dir_bootstrap.py @@ -399,20 +399,45 @@ def _is_bootstrap_call(node: ast.AST) -> bool: return isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "bootstrap_data_dir" +#: 允许**先于** `bootstrap_data_dir` 被导入的 `delector.*` 模块(刻意显式枚举,两个分支共用)。 +#: +#: - `delector.core.data_dir_bootstrap`:bootstrap 自身,必须**先**被导入才能被调用。 +#: - `delector.core.instance_lock`:抢锁必须先于 bootstrap(spec §3.2:抢不到锁就不该搬库)。 +#: 它能进这张表的**唯一凭据**是「不 import `delector.database`」—— 由 +#: `test_instance_lock_module_does_not_import_database` 钉住;否则它在设 env 之前 +#: 把 `database` 拉起来,`DATA_DIR` 就被冻结在旧位置。 +#: +#: 为什么加白名单(而不是靠守卫本来就不会红):`test_start_py_bootstrap_precedes_every_delector_import` +#: 判的是**全模块最小行号**,而 `start.py:39` 的 `data_dir_bootstrap` 导入恒为最小值 ⇒ +#: 新增 `instance_lock` 导入**不会**让那条守卫变红。放行纯粹是**意图显式**:下一个人看到 +#: `instance_lock` 排在 bootstrap 之前,能在这里查到「这是批准过的例外」,而不是顺手写歪的顺序。 +#: +#: 维护提示:**MUST NOT** 把那条顺序守卫改成「逐行比较」—— 它会连带要求挪动 `probe_identity` +#: 的惰性导入,超出本计划范围。 +_BOOTSTRAP_SAFE_IMPORTS = frozenset({"delector.core.data_dir_bootstrap", "delector.core.instance_lock"}) + + def _is_delector_import(node: ast.AST) -> bool: - """`delector.*` 的导入——排除 bootstrap 自身(它必须**先**被导入才能被调用)。""" + """`delector.*` 的导入——排除白名单 `_BOOTSTRAP_SAFE_IMPORTS` 里批准过的例外。""" if isinstance(node, ast.ImportFrom): module = node.module or "" - return module.startswith("delector") and module != "delector.core.data_dir_bootstrap" + return module.startswith("delector") and module not in _BOOTSTRAP_SAFE_IMPORTS if isinstance(node, ast.Import): - return any(a.name.startswith("delector") and a.name != "delector.core.data_dir_bootstrap" for a in node.names) + return any(a.name.startswith("delector") and a.name not in _BOOTSTRAP_SAFE_IMPORTS for a in node.names) return False def _mentions_database(node: ast.AST) -> bool: - """该导入是否指向 `database` 模块(只看最后一段,兼容 `import a.b.database` 两种写法)。""" + """该导入是否指向 `database` 模块(只看最后一段,三种写法都要抓)。 + + 覆盖 `import delector.database` / `from delector.database import x` / + `from delector import database`:最后一种模块名落在 alias 上,只查 `node.module` 会漏 + ⇒ 那正是「看着在跑、实际恒真」的静默失效(`PROJECT_OVERVIEW.md` 红线 13 同型)。 + """ if isinstance(node, ast.ImportFrom): - return (node.module or "").split(".")[-1] == "database" + if (node.module or "").split(".")[-1] == "database": + return True + return any(a.name.split(".")[-1] == "database" for a in node.names) if isinstance(node, ast.Import): return any(a.name.split(".")[-1] == "database" for a in node.names) return False @@ -488,6 +513,23 @@ def test_bootstrap_module_does_not_import_database_at_module_level(): assert not offenders, "data_dir_bootstrap 不得在模块级导入 database(会抢在设 env 之前冻结 DATA_DIR)" +def test_instance_lock_module_does_not_import_database(): + """`instance_lock` 同样**不得**在模块级 import `database`:它是 bootstrap 的「前哨」。 + + `start.py` 要**先**抢占单实例锁、**后**才 `bootstrap_data_dir`(spec §3.2:抢不到锁就 + 不该去搬库)。这条顺序只有在锁模块自己不碰 `database` 时才安全 —— 一旦它在模块顶层 + (或经由 `from delector import database` 这类写法)把 `database` 拉起来,`DATA_DIR` 就 + 在设 env 之前落定了,bootstrap 随后改 env 毫无作用:与 bootstrap 自身那条用例同构的 + 「测试全绿、用户照旧丢数据」失败。故本用例是 `_BOOTSTRAP_SAFE_IMPORTS` 放行 + `delector.core.instance_lock` 的**唯一凭据**:白名单放人,这条钉住他真干净。 + """ + path = REPO_ROOT / "delector" / "core" / "instance_lock.py" + tree = ast.parse(path.read_text(encoding="utf-8")) + + offenders = [n for n in tree.body if isinstance(n, (ast.Import, ast.ImportFrom)) and _mentions_database(n)] + assert not offenders, "instance_lock 不得在模块级导入 database(会抢在 bootstrap 设 env 之前冻结 DATA_DIR)" + + def test_legacy_dir_matches_database_repo_root(): """bootstrap 推导的旧落点必须与 `database._REPO_ROOT` **恒等**:搬错起点 = 搬不到数据。 diff --git a/tests/test_desktop_port_guard.py b/tests/test_desktop_port_guard.py index 8c8f8b8..4b2e9c8 100644 --- a/tests/test_desktop_port_guard.py +++ b/tests/test_desktop_port_guard.py @@ -70,6 +70,10 @@ def _stub_environment( mod, "_message_box", lambda title, detail: events.append(("box", title, detail)) ) monkeypatch.setenv("DELECTOR_DATA_DIR", str(tmp_path)) + # Task 7:判定点换成 `claim_instance` 后会**真去取锁** ⇒ 必须钉落点,否则每跑一条用例就往 + # 真实 `%LOCALAPPDATA%\DeLector` 写一把锁与一份 instance.json,且结果取决于开发机上此刻 + # 是否真有 DeLector 在跑(随机器漂移的假红/假绿)。 + _pin_lock_dir(monkeypatch, tmp_path) import start from delector.core import server_lifecycle @@ -79,6 +83,45 @@ def _stub_environment( monkeypatch.setattr(server_lifecycle, "probe_identity", lambda port, **_k: identity) +def _pin_lock_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Any: + """把锁落点钉到 `tmp_path`(理由见 `_stub_environment` 里那段注释)。""" + from delector.core import instance_lock + + paths = instance_lock.LockPaths( + lock=str(tmp_path / "instance.lock"), + meta=str(tmp_path / "instance.json"), + ) + # 调用点不在本模块内:start.py::claim_instance 于**调用期**经 `LockPaths.for_platform(env)` + # 做类属性查找,故本补丁即时生效;instance_lock 自身不调用它。 + # mock-drift-ok: 符号消费方在被测模块 start.py::claim_instance(调用期类属性查找),补丁即时生效 + monkeypatch.setattr(instance_lock.LockPaths, "for_platform", staticmethod(lambda env: paths)) + return paths + + +def _hold_lock(paths: Any, data_dir: str, version: str, port: int = 8000) -> Any: + """模拟「另一个 DeLector 正持锁」:走**真** `try_acquire`,不是伪造返回值。 + + 伪造返回值会让「锁被判据读成对方记录」这条链失去意义(能把 bug 造进实现里而自证通过); + 真持锁还顺带证明 Windows/POSIX 上同进程第二个句柄确实冲突。 + """ + from delector.core import instance_lock + + holder = instance_lock.try_acquire( + paths, + instance_lock.LockRecord( + pid=4242, + version=version, + port=port, + data_dir=data_dir, + acquired_at="2026-10-22T09:00:00+08:00", + ), + ) + assert isinstance(holder, instance_lock.LockHandle), ( + "模拟「另一个实例持锁」失败(前提变了:同进程第二次取锁应当被挡)" + ) + return holder + + def _stub_fake_server_module(monkeypatch: pytest.MonkeyPatch) -> None: """把 `delector.server` 换成假模块:`from delector.server import app` 只取 `app` 属性。 @@ -200,27 +243,173 @@ def test_run_desktop_inner_does_not_build_server_when_port_is_taken( assert ("build_server", 8000) not in events, "旧实例在跑 ⇒ 绝不能再建第二个服务" -def test_port_taken_by_delector_pops_dialog_and_logs( +def test_same_instance_pops_dialog_with_version_pid_and_port( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): - """失败路径必须**弹窗 + 日志**(不许只写日志):文案点明"已有 DeLector 在运行"。""" + """失败路径必须**弹窗 + 日志**(不许只写日志):文案点明"已有 DeLector 在运行"。 + + Task 7:判据从「端口上有没有东西」换成「锁被谁持有」。故这里不再 patch 端口/身份,而是 + 让另一个实例**真持锁**(同版本、同数据目录)⇒ `reason == "same"`。标题、正文的三要素 + (版本 / pid / 端口)缺一都转红:用户要靠端口去找那个窗口,靠版本判断是不是旧构建。 + """ + from delector.core.version import APP_VERSION + mod = _mod() events: List[Any] = [] _stub_environment(monkeypatch, mod, tmp_path, events, occupied=True, identity=True) _stub_server_chain(monkeypatch, mod, events) _install_fake_webview(monkeypatch, mod) log_path = tmp_path / "launch.log" + holder = _hold_lock( + _pin_lock_dir(monkeypatch, tmp_path), data_dir=str(tmp_path), version=APP_VERSION, port=8000 + ) + try: + code = mod._run_desktop_inner(8000, str(log_path)) + finally: + holder.release() - mod._run_desktop_inner(8000, str(log_path)) - + assert code == 3, "已有同版本实例 ⇒ 拒绝启动(退出码 3)" boxes = [e for e in events if e[0] == "box"] assert boxes, "必须弹窗(用户唯一可见的失败反馈)" - assert "DeLector" in boxes[0][1], "弹窗标题必须点名 DeLector 已在运行" - assert "端口 8000" in boxes[0][2], "弹窗必须写明端口号" - assert "退出旧实例" in boxes[0][2], "弹窗必须给出行动指引(退出旧实例)" + assert "DeLector 已在运行" in boxes[0][1], f"弹窗标题必须点名已在运行,实际 {boxes[0][1]!r}" + body = boxes[0][2] + assert f"v{APP_VERSION}" in body, "弹窗必须写明对方版本" + assert "4242" in body, "弹窗必须写明对方进程号(用户据此在任务管理器里找到它)" + assert "端口 8000" in body, "弹窗必须写明端口号" + assert "http://127.0.0.1:8000" in body, "弹窗必须给出可直接打开的地址" content = log_path.read_text(encoding="utf-8") - assert "8000" in content and "DeLector" in content, "拒绝原因必须落 launch.log" + assert "same" in content and "8000" in content, "拒绝原因必须落 launch.log" + + +def test_version_mismatch_dialog_names_both_versions( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """版本不同 ⇒ 文案必须**同时**给出对方与本程序的版本(否则用户不知道该升级哪边)。""" + from delector.core.version import APP_VERSION + + mod = _mod() + events: List[Any] = [] + _stub_environment(monkeypatch, mod, tmp_path, events, occupied=False, identity=False) + _stub_server_chain(monkeypatch, mod, events) + _install_fake_webview(monkeypatch, mod) + log_path = tmp_path / "launch.log" + holder = _hold_lock( + _pin_lock_dir(monkeypatch, tmp_path), data_dir=str(tmp_path), version="0.0.1", port=8000 + ) + try: + code = mod._run_desktop_inner(None, str(log_path)) + finally: + holder.release() + + assert code == 3 + assert ("build_server", 8000) not in events + body = [e for e in events if e[0] == "box"][0][2] + assert "v0.0.1" in body and f"v{APP_VERSION}" in body, f"必须给出双方版本,实际:{body!r}" + + +def test_degraded_lock_uses_identity_probe_before_falling_back( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """第二腿(Task 7 的关键漏口):锁降级时 8000 上已有 DeLector ⇒ **不得**顺延起第二个服务。 + + 锁降级(只读目录 / 无锁原语)时没有权威判据,若只按「端口被占」顺延到 8002,两个进程就会 + 同时改同一个库。这里让 `_acquire_os_lock` 恒返 None(降级),8000 被占且身份探测为真 ⇒ + 必须判成 `same` 并拒绝启动。删掉 `_check_port_or_fail` 里的 `identity_probe=_identity_probe` + 或 `_identity_probe` 本身,本用例转红。 + """ + from delector.core import instance_lock + + mod = _mod() + events: List[Any] = [] + _stub_environment(monkeypatch, mod, tmp_path, events, occupied=True, identity=True) + _stub_server_chain(monkeypatch, mod, events) + _install_fake_webview(monkeypatch, mod) + monkeypatch.setattr(instance_lock, "_acquire_os_lock", lambda fd: None) + log_path = tmp_path / "launch.log" + + code = mod._run_desktop_inner(None, str(log_path)) + + assert code == 3, "降级锁 + 端口上确有一个 DeLector ⇒ 必须拒绝启动,绝不能顺延" + assert not any(e[0] == "build_server" for e in events), "绝不许为同一个库起第二个服务" + body = [e for e in events if e[0] == "box"][0][2] + assert "已有 DeLector 在运行" in body, f"文案必须说明是已有实例,实际:{body!r}" + # 降级路径读不到 meta ⇒ version="unknown"、pid=0;占位值绝不能出现在人话里。 + assert "unknown" not in body and "进程 0" not in body, f"文案不得含占位值,实际:{body!r}" + assert "端口 8000" in body, "必须点名探测到的真实端口" + + +def test_auto_mode_falls_back_to_8002_when_8000_is_foreign( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """自动模式(`port=None`):8000 被陌生程序占 ⇒ 顺延到 8002 并照常建服。""" + mod = _mod() + events: List[Any] = [] + _stub_environment(monkeypatch, mod, tmp_path, events, occupied=False, identity=False) + _stub_server_chain(monkeypatch, mod, events) + _install_fake_webview(monkeypatch, mod) + + import start + + monkeypatch.setattr(start, "is_port_in_use", lambda port: port == 8000) + + code = mod._run_desktop_inner(None, str(tmp_path / "launch.log")) + + assert code == 0 + assert ("build_server", 8002) in events, f"必须顺延到 8002 建服,实际事件:{events}" + + +def test_explicit_port_does_not_fall_through(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """显式 `--port 8000` 被占 ⇒ 报错退出,**不**顺延(调用方已对外承诺那个地址)。""" + mod = _mod() + events: List[Any] = [] + _stub_environment(monkeypatch, mod, tmp_path, events, occupied=False, identity=False) + _stub_server_chain(monkeypatch, mod, events) + _install_fake_webview(monkeypatch, mod) + + import start + + monkeypatch.setattr(start, "is_port_in_use", lambda port: port == 8000) + + code = mod._run_desktop_inner(8000, str(tmp_path / "launch.log")) + + assert code == 3, "显式端口被占 ⇒ 退出码 3,绝不偷偷换端口" + assert not any(e[0] == "build_server" for e in events) + body = [e for e in events if e[0] == "box"][0][2] + assert "端口 8000" in body, f"文案必须点名是哪个端口,实际:{body!r}" + + +def test_all_exit_paths_release_the_instance_lock( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """**所有**返回路径都必须放锁:否则同进程重试(壳的"失败后重开"、测试)会撞上自己的锁。 + + 覆盖两条:正常返回 0、缺 WebView2 返回 2。 + """ + from delector.core import instance_lock + + mod = _mod() + + for return_code, ensure_webview in ((0, True), (2, False)): + events: List[Any] = [] + case_dir = tmp_path / f"case-{return_code}" + case_dir.mkdir() + _stub_environment(monkeypatch, mod, case_dir, events, occupied=False, identity=False) + _stub_server_chain(monkeypatch, mod, events) + _install_fake_webview(monkeypatch, mod) + monkeypatch.setattr(mod, "ensure_webview2_runtime", lambda flag=ensure_webview: flag) + paths = _pin_lock_dir(monkeypatch, case_dir) + + assert mod._run_desktop_inner(None, str(case_dir / "launch.log")) == return_code + + again = instance_lock.try_acquire( + paths, + instance_lock.LockRecord(pid=1, version="x", port=8000, data_dir="", acquired_at=""), + ) + assert isinstance(again, instance_lock.LockHandle), ( + f"退出码 {return_code} 的路径没有放锁:同进程重试会被判成『已有实例在运行』" + ) + again.release() # ── 分支③:被占但不是 DeLector ⇒ 弹窗 + 日志 + 退出码 3 ─────────────────────── diff --git a/tests/test_desktop_shell.py b/tests/test_desktop_shell.py index 038f2e6..dd73af7 100644 --- a/tests/test_desktop_shell.py +++ b/tests/test_desktop_shell.py @@ -439,25 +439,50 @@ def _spy(log_path: str, debug_console: bool = False) -> None: assert "[警告]" in content and "--port 9000" in content +def _stub_claim_already_running( + monkeypatch: pytest.MonkeyPatch, start: Any, tmp_path: Path, port: int = 8000 +) -> None: + """把 `start.claim_instance` 钉成「已有一个同版本实例在跑」。 + + Task 6 之后 `main()` 的早退判据是**锁**(`claim_instance`),不再是 `is_port_in_use(8000)`。 + 不换注入点的话,`is_port_in_use` 的桩再也拦不住启动 ⇒ 用例会真去起服务并**挂死**。 + """ + from delector.core.instance_lock import LockRecord + from delector.core.version import APP_VERSION + + record = LockRecord( + pid=4321, + version=APP_VERSION, + port=port, + data_dir=str(tmp_path), + acquired_at="2026-10-22T09:00:00+08:00", + ) + monkeypatch.setattr( + start, "claim_instance", lambda *a, **k: start.AlreadyRunning(record=record, reason="same") + ) + + def test_start_main_entry_ensures_streams_before_existing_port_notice( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """start.py 单独作为入口时,端口占用提示之前必须自行完成流收口。""" + """start.py 单独作为入口时,「已在运行」提示之前必须自行完成流收口。""" import start from delector.core import server_lifecycle monkeypatch.setenv("DELECTOR_DATA_DIR", str(tmp_path)) monkeypatch.setattr(start, "is_android", lambda: True) - monkeypatch.setattr(start, "is_port_in_use", lambda _port: True) monkeypatch.setattr(server_lifecycle, "probe_identity", lambda _port: True) + _stub_claim_already_running(monkeypatch, start, tmp_path) _run_with_missing_streams(monkeypatch, start.main) content = (tmp_path / "launch.log").read_text(encoding="utf-8") - assert "[提示] DeLector 已在端口 8000 运行" in content + assert "[提示] DeLector 已在运行" in content, f"提示必须先完成收口再输出,实际日志:{content!r}" -def test_start_entry_streams_survives_missing_desktop_module(monkeypatch: pytest.MonkeyPatch) -> None: +def test_start_entry_streams_survives_missing_desktop_module( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Android APK 里没有 `desktop.py` ⇒ 入口收口必须**静默放弃**,绝不得冒异常。 为什么必须有这条:APK 的 Python 源集只有 `start.py` + `delector/`(build-release.yml @@ -475,8 +500,10 @@ def test_start_entry_streams_survives_missing_desktop_module(monkeypatch: pytest monkeypatch.setitem(sys.modules, "desktop", None) # ⇒ `import desktop` 抛 ImportError monkeypatch.setattr(start, "is_android", lambda: True) # 不起浏览器线程 - monkeypatch.setattr(start, "is_port_in_use", lambda _port: True) monkeypatch.setattr(server_lifecycle, "probe_identity", lambda _port: True) + # Task 6:早退判据是锁(`claim_instance`)而非 `is_port_in_use`;不换注入点 ⇒ 本用例会真去 + # 起服务并挂死(`is_port_in_use` 的桩拦不住新代码)。 + _stub_claim_already_running(monkeypatch, start, tmp_path) original_out, original_err = sys.stdout, sys.stderr # 收口前捕获既有流对象引用 diff --git a/tests/test_desktop_window_lifecycle.py b/tests/test_desktop_window_lifecycle.py index 9c868be..dec5b34 100644 --- a/tests/test_desktop_window_lifecycle.py +++ b/tests/test_desktop_window_lifecycle.py @@ -30,6 +30,7 @@ _parse_desktop, _RecordingCtypes, _stub_server_build, + stmts_through_try, ) from server_helpers import _isolate_data_dir as _isolate_data_dir @@ -279,9 +280,16 @@ def _stage_log_entries(func_name: str) -> List[Tuple[int, str]]: def _stage_stmt_span(func_name: str, predicate: Any) -> Tuple[int, int]: - """`func_name` 里首个满足 `predicate` 的顶层语句的(起始行, 结束行)。找不到即报错。""" + """`func_name` 里首个满足 `predicate` 的语句的(起始行, 结束行)。找不到即报错。 + + Task 7 适配:端口判定整段被 `try:`(放锁)包住 ⇒ `ensure_webview2_runtime` 与 + `_show_window` 从「函数顶层语句」变成「顶层 `try` 体里的语句」。故候选集合 = 函数顶层 + 语句 **+ 一层** `ast.Try` 的 body / handlers / orelse / finalbody —— 只下潜一层是有意的: + 夹逼断言的语义是"标记夹在相邻两条语句之间",下潜到任意深度会让锚点漂到内层无关语句上, + 反而失去判别力。 + """ fn = _funcs_by_name(_parse_desktop())[func_name] - for stmt in fn.body: + for stmt in stmts_through_try(fn.body): if predicate(stmt): return stmt.lineno, stmt.end_lineno or stmt.lineno raise AssertionError(f"{func_name} 里找不到满足断言的语句(谓词={predicate!r})") diff --git a/tests/test_first_screen_from_log.py b/tests/test_first_screen_from_log.py index b3d5415..47e470b 100644 --- a/tests/test_first_screen_from_log.py +++ b/tests/test_first_screen_from_log.py @@ -15,6 +15,7 @@ from typing import Dict, List, Optional, Tuple import pytest +from server_helpers import stmts_through_try ROOT = Path(__file__).resolve().parent.parent SCRIPT = ROOT / "tools" / "first_screen_from_log.py" @@ -150,6 +151,10 @@ def _stage_marker_statements(func: ast.FunctionDef) -> List[ast.stmt]: len(stmts), ) return stmts[:cut] + # `_run_desktop_inner`:Task 7 起主体被 `try/finally` 包住(放锁),①~③ 因此下沉一层。 + # 用共享的 `stmts_through_try` 展开容器(只展开 `try`、不进 `if/for`:WebView2 失败分支的 + # 日志嵌在 `if` 内,不属于这 6 枚)。不展开 ⇒ 只数到 1 枚,本文件的 6 枚断言会红。 + return list(stmts_through_try(func.body)) return list(func.body) diff --git a/tests/test_instance_lock.py b/tests/test_instance_lock.py new file mode 100644 index 0000000..4977a6c --- /dev/null +++ b/tests/test_instance_lock.py @@ -0,0 +1,722 @@ +# -*- coding: utf-8 -*- +r"""`instance_lock` 的记录/路径/元数据读写契约(Task 1)。 + +为什么单独钉 `for_platform` +-------------------------- +锁路径必须是**全局唯一**(ADR-0019 §6.4),与数据目录无关。若这里跟着 `resolve_data_dir` +走,便携版与安装版就会各锁各的 —— 两个窗口、两个库,正是单实例要消灭的状态面。 +故用显式 env 字典分别模拟 Windows / POSIX,断言**完整字面落点**,而不是依赖跑测试的 +主机平台(CI 与本机平台不同,跟着平台走的断言会在另一边静默失效)。 + +为什么钉「读损坏 ⇒ None」而不是抛 +-------------------------------- +`instance.json` 是**元数据**、不是锁本体:锁被持有但 meta 半截/缺失是合法中间态(spec §3.1)。 +此时读方必须降级成「版本未知」,绝不能把 JSON 解析异常抛到启动主链上。每个字段单独变异 +一次(其余字段保持合法),否则某条 `isinstance` 守卫被删掉时,别的守卫会替它兜底, +测试照样全绿 —— 这正是「测试没咬住」的形态。 + +为什么钉「回写不碰 instance.lock」 +--------------------------------- +POSIX 的 `flock` 锁的是 inode:若 `os.replace` 的目标写成 `paths.lock`,锁会指向已 unlink +的旧 inode,**单实例静默失效**。故这里同时钉锁文件的内容与 inode 不被回写改变。 +""" + +from __future__ import annotations + +import contextlib +import errno +import json +import logging +import os +import queue +import subprocess +import sys +import threading +import time +from dataclasses import dataclass, field, replace +from pathlib import Path +from types import SimpleNamespace +from typing import IO, Callable, List, Optional, Tuple, cast + +import pytest + +from delector.core import instance_lock +from delector.core.instance_lock import ( + UNKNOWN_RECORD, + LockHandle, + LockPaths, + LockRecord, + read_record, + try_acquire, + write_record, +) + + +def _paths(tmp_path: Path) -> LockPaths: + """临时目录下的锁路径;不调用 `for_platform`,避免用例互相依赖。""" + return LockPaths( + lock=str(tmp_path / "instance.lock"), + meta=str(tmp_path / "instance.json"), + ) + + +def _handle(paths: LockPaths) -> object: + """Task 1 只约定 `handle.paths`;用最小替身,不提前引入 Task 2 的 `LockHandle`。""" + return SimpleNamespace(paths=paths) + + +def _as_slash(path: str) -> str: + """断言字面落点前统一成 `/`:两种分隔符都替换,断言才与跑测主机平台无关。""" + return path.replace("\\", "/") + + +_VALID_META: dict[str, object] = { + "pid": 4321, + "version": "5.19.0", + "port": 8002, + "data_dir": r"D:\学习资料\DeLector", + "acquired_at": "2026-10-21T12:00:00+08:00", +} + + +def _meta_with(field: str, value: object) -> dict[str, object]: + """复制一份合法记录、只改一个字段:保证失败能归因到那一条守卫。""" + meta = dict(_VALID_META) + meta[field] = value + return meta + + +def _json_text(payload: object) -> str: + return json.dumps(payload, ensure_ascii=False) + + +# 每条 (case_id, 文件原文);原文为 None 表示文件不存在。 +_CORRUPT_CASES: List[Tuple[str, Optional[str]]] = [ + ("missing", None), + ("not-json", "{not json"), + ("non-dict-list", _json_text([1, 2])), + ("non-dict-string", _json_text("hello")), + ("non-dict-null", "null"), + ("pid-wrong-type", _json_text(_meta_with("pid", "x"))), + ("version-wrong-type", _json_text(_meta_with("version", 519))), + ("port-wrong-type", _json_text(_meta_with("port", "8000"))), + ("data-dir-wrong-type", _json_text(_meta_with("data_dir", None))), + ("acquired-at-wrong-type", _json_text(_meta_with("acquired_at", ["x"]))), + ("pid-bool", _json_text(_meta_with("pid", True))), + ("port-bool", _json_text(_meta_with("port", False))), + # 缺字段必须显式钉:`data.get()` 返回 None 与「字段类型错」是两条不同的失败面。 + ("missing-fields-only-pid", _json_text({"pid": 1})), + ( + "missing-field-acquired-at", + _json_text({"pid": 1, "version": "5.19.0", "port": 8000, "data_dir": "D:/x"}), + ), +] + + +def test_write_then_read_roundtrip(tmp_path: Path) -> None: + paths = _paths(tmp_path) + record = LockRecord( + pid=4321, + version="5.19.0", + port=8002, + data_dir=r"D:\学习资料\DeLector", + acquired_at="2026-10-21T12:00:00+08:00", + ) + + write_record(_handle(paths), record) + + assert read_record(paths) == record + + +@pytest.mark.parametrize("case_id, meta_text", _CORRUPT_CASES, ids=[case for case, _ in _CORRUPT_CASES]) +def test_read_record_returns_none_for_missing_and_corrupt( + tmp_path: Path, case_id: str, meta_text: Optional[str] +) -> None: + paths = _paths(tmp_path) + if meta_text is not None: + Path(paths.meta).write_text(meta_text, encoding="utf-8") + + assert read_record(paths) is None, f"case={case_id}" + + +def test_for_platform_windows_and_posix() -> None: + win = LockPaths.for_platform({"LOCALAPPDATA": r"C:\Users\x\AppData\Local"}) + posix = LockPaths.for_platform({"HOME": "/home/x", "XDG_DATA_HOME": "/home/x/.local/share"}) + + assert _as_slash(win.lock) == "C:/Users/x/AppData/Local/DeLector/instance.lock" + assert _as_slash(win.meta) == "C:/Users/x/AppData/Local/DeLector/instance.json" + assert _as_slash(posix.lock) == "/home/x/.local/share/DeLector/instance.lock" + assert _as_slash(posix.meta) == "/home/x/.local/share/DeLector/instance.json" + + assert win.lock != win.meta + assert posix.lock != posix.meta + + +def test_write_record_is_atomic_and_leaves_no_temp_files(tmp_path: Path) -> None: + paths = _paths(tmp_path) + lock_path = Path(paths.lock) + lock_path.write_bytes(b"sentinel") + before = lock_path.stat() + record = LockRecord( + pid=1, + version="5.19.0", + port=8000, + data_dir=str(tmp_path / "数据"), + acquired_at="2026-10-21T12:00:00+08:00", + ) + + write_record(_handle(paths), record) + + assert lock_path.read_bytes() == b"sentinel" + if before.st_ino != 0: # Windows 某些文件系统不提供稳定 inode,此时只靠内容钉住 + assert lock_path.stat().st_ino == before.st_ino + assert sorted(os.listdir(tmp_path)) == ["instance.json", "instance.lock"] + payload = json.loads(Path(paths.meta).read_text(encoding="utf-8")) + assert payload["pid"] == record.pid + assert payload["data_dir"] == record.data_dir + + +def test_write_record_cleans_temp_on_non_oserror_failure(tmp_path: Path) -> None: + """清理必须挂在 `finally`:`json.dump` 抛 TypeError 时同样不能留半截 `.tmp`。""" + paths = _paths(tmp_path) + record = LockRecord( + pid=1, + version=cast(str, object()), + port=8000, + data_dir=str(tmp_path), + acquired_at="2026-10-21T12:00:00+08:00", + ) + + with pytest.raises(TypeError): + write_record(_handle(paths), record) + + leftovers = [name for name in os.listdir(tmp_path) if name.endswith(".tmp")] + assert leftovers == [] + + +def test_read_record_accepts_bom_prefixed_meta(tmp_path: Path) -> None: + """Windows 编辑器可能给 JSON 加 BOM;带 BOM 的元数据仍必须读成完整记录,而不是版本未知。""" + paths = _paths(tmp_path) + payload = b"\xef\xbb\xbf" + json.dumps(_VALID_META, ensure_ascii=False).encode("utf-8") + Path(paths.meta).write_bytes(payload) + + assert read_record(paths) == LockRecord( + pid=4321, + version="5.19.0", + port=8002, + data_dir=r"D:\学习资料\DeLector", + acquired_at="2026-10-21T12:00:00+08:00", + ) + + +# ── 跨进程锁原语 ── +# +# 为什么用「两次独立 os.open」而不是 fork: +# Windows 没有 fork,且 msvcrt.locking / fcntl.flock 的互斥性都绑定在**内核句柄**上, +# 同进程内两次独立 open 得到的正是两个句柄 —— 覆盖的失败面与真子进程相同(Task 3 再验真进程)。 +# 若只对同一个 fd 重复调用,锁永远「成功」,这两个用例会退化成永不失败的空断言。 + + +def _record(tmp_path: Path, port: int = 8000) -> LockRecord: + return LockRecord( + pid=os.getpid(), + version="5.19.0", + port=port, + data_dir=str(tmp_path), + acquired_at="2026-10-21T12:00:00+08:00", + ) + + +def test_second_acquire_returns_holder_record(tmp_path: Path) -> None: + paths = _paths(tmp_path) + record = _record(tmp_path) + + first = try_acquire(paths, record) + assert isinstance(first, LockHandle), f"首次取锁必须拿到 LockHandle,实际是 {type(first).__name__}" + assert first.record.pid == os.getpid() + assert first.record.version == "5.19.0" + + second = try_acquire(paths, record) + assert isinstance(second, LockRecord), f"锁被持有时必须返回对方的 LockRecord,实际是 {type(second).__name__}" + assert second == first.record + + first.release() + + +def test_release_is_idempotent_and_allows_reacquire(tmp_path: Path) -> None: + paths = _paths(tmp_path) + record = _record(tmp_path) + + first = try_acquire(paths, record) + assert isinstance(first, LockHandle) + first.release() + first.release() # 幂等:退出路径可能被调用两次(finally + 上下文管理器) + + again = try_acquire(paths, record) + assert isinstance(again, LockHandle), "release() 之后必须能重新取到锁" + again.release() + + +def test_held_lock_with_missing_meta_returns_unknown_record(tmp_path: Path) -> None: + """锁被持有但 meta 被删(升级残留 / 手工清理)是合法中间态:必须降级成版本未知,不抛异常。""" + paths = _paths(tmp_path) + record = _record(tmp_path) + + handle = try_acquire(paths, record) + assert isinstance(handle, LockHandle) + try: + os.unlink(paths.meta) + second = try_acquire(paths, record) + finally: + handle.release() + + assert isinstance(second, LockRecord), f"meta 缺失时必须返回 unknown 记录,实际是 {type(second).__name__}" + assert second == UNKNOWN_RECORD + assert second.pid == 0 + + +def test_write_record_does_not_void_the_lock(tmp_path: Path) -> None: + """回归哨兵:回写 meta 若误写到 paths.lock(os.replace),锁会指向旧 inode 而静默失效。""" + paths = _paths(tmp_path) + handle = try_acquire(paths, _record(tmp_path, port=8000)) + assert isinstance(handle, LockHandle) + + updated = replace(handle.record, port=8002) + write_record(handle, updated) + + second = try_acquire(paths, updated) + try: + assert isinstance(second, LockRecord), "回写 meta 之后锁必须仍然有效(否则单实例静默失效)" + assert second == updated + finally: + handle.release() + + +# ── 降级响亮度 / errno 归属 / seek(0) / 回写失败不穿出 ── +# +# 为什么这四条必须存在 +# ------------------- +# `except OSError: return False` 把「原语坏了」读成「别人持有锁」:NFS/SMB 上 `flock` 返 `ENOLCK` +# 时既不降级也不打 WARNING,用户只会看到「已有实例在运行」。同理 `write_record` 未包 try 会在 +# Windows 并发读时把 `WinError 32` 抛穿 `try_acquire` 并留下悬空锁 fd。下面四个失败面各钉一条。 +# +# 平台分支交代:两条原语**都在本机跑**(`msvcrt` 分支与 `fcntl` 分支都走假模块,不靠 skipif), +# 只有「seek(0) 的作用」这一条本质是 Windows 语义(POSIX 锁的是整个 inode、与 fd offset 无关), +# 故只写 Windows 分支并在用例内显式说明。 + +#: (原语模块名, 该模块的加锁函数)。本机 `_acquire_os_lock` 实际分派到的那条排在首位 —— +#: 必须**真跑**,不许被 `pytest.mark.skipif` 静默跳掉。 +_PRIMITIVES: List[Tuple[str, Callable[[int], Optional[bool]]]] = [ + ("msvcrt", instance_lock._acquire_windows_lock), + ("fcntl", instance_lock._acquire_posix_lock), +] +_HOST_PRIMITIVE_NAME = "msvcrt" if sys.platform == "win32" else "fcntl" +_PRIMITIVES_UNDER_TEST: List[Tuple[str, Callable[[int], Optional[bool]]]] = sorted( + _PRIMITIVES, key=lambda item: item[0] != _HOST_PRIMITIVE_NAME +) + + +def _fake_primitive_module(name: str, exc: OSError) -> SimpleNamespace: + """假原语模块:名为 `name` 的模块里,唯一的加锁函数直接抛 `exc`(另带调用方会读的常量)。""" + + def _raise(*_args: object) -> None: + raise exc + + if name == "msvcrt": + return SimpleNamespace(locking=_raise, LK_NBLCK=2) + return SimpleNamespace(flock=_raise, LOCK_EX=2, LOCK_NB=4) + + +def test_missing_primitive_warns_exactly_once_and_never_fails_startup( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """降级必须响亮但只喊一次:`_PRIMITIVE_MISSING_WARNED` 此前完全没有回归保护。""" + paths = _paths(tmp_path) + record = _record(tmp_path) + monkeypatch.setattr(instance_lock, "_load_primitive", lambda name: None) + monkeypatch.setattr(instance_lock, "_PRIMITIVE_MISSING_WARNED", False) + + with caplog.at_level(logging.WARNING, logger="delector"): + first = try_acquire(paths, record) + second = try_acquire(paths, record) + + assert isinstance(first, LockHandle), "原语缺失只能降级成句柄,不能抛也不能返回对方记录" + assert first._fd == -1 + assert isinstance(second, LockHandle), "第二次同样降级成句柄" + assert second._fd == -1 + + warnings = [item for item in caplog.records if item.levelno == logging.WARNING] + if len(warnings) != 1: + raise AssertionError(f"降级 WARNING 应恰好 1 条,实际 {len(warnings)} 条:{[i.getMessage() for i in warnings]}") + # WARNING 必须点明「不是真互斥」,否则读日志的人会以为单实例仍成立。 + assert "本次不是真互斥" in warnings[0].getMessage() + + +@pytest.mark.parametrize( + "module_name, acquire", + _PRIMITIVES_UNDER_TEST, + ids=[name for name, _ in _PRIMITIVES_UNDER_TEST], +) +def test_non_held_errno_degrades_but_held_errno_is_false( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + module_name: str, + acquire: Callable[[int], Optional[bool]], +) -> None: + """`ENOLCK`(NFS/SMB 无锁支持)不是「被持有」,必须降级成 `None` 并留 WARNING;`EACCES` 才是。""" + fd = os.open(tmp_path / "instance.lock", os.O_RDWR | os.O_CREAT, 0o600) + try: + monkeypatch.setattr( + instance_lock, + "_load_primitive", + lambda name: _fake_primitive_module(module_name, OSError(errno.ENOLCK, "no locks available")), + ) + with caplog.at_level(logging.WARNING, logger="delector"): + assert acquire(fd) is None, "ENOLCK 是原语不可用,必须降级成 None 而不是『被持有』" + assert any("本次不是真互斥" in item.getMessage() for item in caplog.records), ( + "非持有类 errno 必须留下 WARNING;静默归成『被持有』就是把故障藏起来" + ) + + caplog.clear() + monkeypatch.setattr( + instance_lock, + "_load_primitive", + lambda name: _fake_primitive_module(module_name, OSError(errno.EACCES, "resource busy")), + ) + with caplog.at_level(logging.WARNING, logger="delector"): + assert acquire(fd) is False, "EACCES/EAGAIN 才是『别人持有』,且不应产生降级 WARNING" + assert caplog.records == [], "『被持有』是正常判定结果,不该刷降级日志" + finally: + os.close(fd) + + +def test_windows_lock_pulls_fd_offset_back_to_zero(tmp_path: Path) -> None: + """删掉 `os.lseek(fd, 0, SEEK_SET)` 时本用例转红:那时 B 会锁到 byte 5,与 A 的 byte 0 不冲突。 + + POSIX 的 `flock` 锁的是整个 inode、与 fd offset 无关,等价断言在那边恒真(假模块也证明不了 + offset 无关性),故只写 Windows 分支;在非 win32 主机上运行时显式跳过并说明原因。 + """ + if sys.platform != "win32": + pytest.skip("msvcrt 区间锁语义专属:POSIX flock 锁整个 inode,offset 断言在那边恒真") + + lock_path = tmp_path / "instance.lock" + fd_a = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600) + fd_b = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600) + try: + assert instance_lock._acquire_windows_lock(fd_a) is True + os.lseek(fd_b, 5, os.SEEK_SET) + # 实现必须把 offset 拉回 0,否则 B 会锁在 byte 5、与 A 的区间不冲突,从而误判为成功。 + assert instance_lock._acquire_windows_lock(fd_b) is False + finally: + os.close(fd_b) + os.close(fd_a) + + +def test_write_record_failure_does_not_escape_try_acquire( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """回写失败只影响「别人看到谁」,不影响互斥:不得抛,也不得丢掉已持有的锁。""" + paths = _paths(tmp_path) + record = _record(tmp_path) + + def _boom(handle: object, new_record: LockRecord) -> None: + raise OSError(errno.EIO, "模拟回写失败") + + monkeypatch.setattr(instance_lock, "write_record", _boom) + + with caplog.at_level(logging.WARNING, logger="delector"): + handle = try_acquire(paths, record) + + assert isinstance(handle, LockHandle), "回写失败不得把异常穿出 try_acquire(会留下悬空锁 fd)" + assert handle._fd >= 0, "锁已经拿到,句柄必须带着真 fd" + assert any("本次不是真互斥" in item.getMessage() for item in caplog.records), "回写失败必须留痕" + + try: + second = try_acquire(paths, record) + finally: + handle.release() + + assert isinstance(second, LockRecord), "第一次拿到的锁必须仍然有效(第二次应撞上持有者而不是重新上锁)" + + +def test_acquire_creates_parent_dir_of_both_lock_and_meta(tmp_path: Path) -> None: + """锁与 meta 分处两个目录时两个父目录都要建:只建 meta 父目录 ⇒ `os.open` 抛 ENOENT ⇒ 降级。 + + 现有用例的 `LockPaths` 全部由 `_paths()` 造(同目录),把这条修复退回只建 meta 父目录时 + 28 条仍全绿 —— 故这里显式拆成 `a/` 与 `b/` 两个待建目录。 + """ + paths = LockPaths( + lock=str(tmp_path / "a" / "instance.lock"), + meta=str(tmp_path / "b" / "instance.json"), + ) + + handle = try_acquire(paths, _record(tmp_path)) + assert isinstance(handle, LockHandle), f"不同目录也必须取到句柄,实际是 {type(handle).__name__}" + try: + assert handle._fd >= 0, "不同目录也必须拿到真锁(降级至 _fd == -1 说明 lock 的父目录没建)" + finally: + handle.release() + + +def test_non_held_errno_does_not_consume_missing_primitive_flag( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """`ENOLCK` 是「原语存在但加锁被拒」,不是「原语缺失」:不得冒充,也不得吃掉那次唯一告警。 + + `_PRIMITIVE_MISSING_WARNED` 被误置真时,同进程后续**真·原语缺失**会零 warning(二阶静默), + 故这里用例开始先显式归零,不依赖执行顺序。 + """ + paths = _paths(tmp_path) + monkeypatch.setattr(instance_lock, "_PRIMITIVE_MISSING_WARNED", False) + monkeypatch.setattr( + instance_lock, + "_load_primitive", + lambda name: _fake_primitive_module(_HOST_PRIMITIVE_NAME, OSError(errno.ENOLCK, "no locks available")), + ) + + with caplog.at_level(logging.WARNING, logger="delector"): + handle = try_acquire(paths, _record(tmp_path)) + + assert isinstance(handle, LockHandle), "加锁被拒只能降级成句柄,不能抛也不能返回对方记录" + assert handle._fd == -1 + messages = [item.getMessage() for item in caplog.records] + assert any("本次不是真互斥" in message for message in messages), "加锁被拒必须留一条含真实 errno 的 WARNING" + assert not any("无可用的文件锁原语" in message for message in messages), ( + "ENOLCK 不是原语缺失:再喊一条『无可用的文件锁原语』既是假陈述,也会让后续真缺失静默" + ) + assert instance_lock._PRIMITIVE_MISSING_WARNED is False, "加锁被拒不得置『原语缺失已告警』旗标" + + +def test_unserializable_record_releases_fd_and_allows_reacquire( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """记录不可序列化(`TypeError`)时:不抛、响亮降级,且锁 fd 必须真被收尸(否则本次进程再也取不到锁)。""" + paths = _paths(tmp_path) + bad = LockRecord( + pid=1, + version=cast(str, object()), + port=8000, + data_dir=str(tmp_path), + acquired_at="2026-10-21T12:00:00+08:00", + ) + + with caplog.at_level(logging.WARNING, logger="delector"): + degraded = try_acquire(paths, bad) + + assert isinstance(degraded, LockHandle), "序列化失败不得把异常穿出 try_acquire(模块承诺启动期不抛)" + assert degraded._fd == -1, "已释放锁的句柄必须是降级句柄" + assert any("本次不是真互斥" in item.getMessage() for item in caplog.records), "显式点名必须留痕" + + again = try_acquire(paths, _record(tmp_path)) + assert isinstance(again, LockHandle), "锁必须真的被释放:后续取锁应成功,而不是撞上自己悬空的 fd" + again.release() + + +# ── 跨进程(真 spawn)── +# +# 为什么必须另开这一节 +# ------------------- +# 上面所有用例都跑在**一个进程**里:互斥性只经「同进程两次 `os.open` 得到两个内核句柄」间接验证, +# 这盖不住两件只有真进程才能验的事: +# ① 锁在**另一个 OS 进程**手里时,本进程必须被真的挡住(不是句柄不同,而是内核拒绝); +# ② 子进程被 `terminate()` 后,锁必须**随进程退出自愈** —— 本模块刻意不做 pid 探活, +# 唯一依赖就是「OS 释放已死进程的锁」,这条承诺此前一条测试都没钉。 +# Windows 没有 fork,`python -c` + spawn 是唯一可移植路径,正好也是产品真实形态 +# (`start.py` 在用户双击时就是这么跑起来的)。 +# +# 反脆弱约定(都踩过): +# - 等待子进程的 `HELD` 用**队列 + 硬上界**,不用裸 sleep 轮询; +# - `finally` 必收尸:`terminate()` → `wait(10)` → `kill()` 兜底 → `wait(10)`,绝不留下裸 sleep 的子进程; +# - 任何断言失败都把子进程 stdout/stderr 原文塞进消息:否则「子进程其实没拿到锁」会被读成父进程的 bug。 + +#: 子进程等锁期间在 `HELD` 之后保持持有的时长:远大于父进程全部动作,靠 `terminate()` 结束(不是靠它自己退出)。 +_CHILD_HOLD_SECONDS = 30 +#: 等 `HELD` 的上界。子进程 import 本仓库代码约 0.3s,10s 是「本机不可能慢成这样」的余量。 +_HELD_TIMEOUT = 10.0 +#: 收尸上界:`terminate()` 之后的 `wait()` 与 `kill()` 兜底的 `wait()` 都用它。 +_TERMINATE_TIMEOUT = 10.0 +#: 队列 EOF 哨兵:泵线程读到流结束就投它,让「子进程提前死了」立刻变成可诊断失败而不是干等 10s。 +_CHILD_EOF = "" + +_CHILD_SCRIPT = r''' +import os +import sys +import time + +from delector.core.instance_lock import LockHandle, LockPaths, LockRecord, try_acquire + +base = sys.argv[1] +paths = LockPaths( + lock=os.path.join(base, "instance.lock"), + meta=os.path.join(base, "instance.json"), +) +record = LockRecord( + pid=os.getpid(), + version="5.19.0", + port=8000, + data_dir=base, + acquired_at="2026-10-21T12:00:00+08:00", +) +handle = try_acquire(paths, record) +if not isinstance(handle, LockHandle): + # 子进程自己没拿到锁:父进程后面「被挡住」就可能是假的,必须显式失败并把身份打到 stderr。 + sys.stderr.write("CHILD_NOT_HOLDER %r\n" % (handle,)) + sys.stderr.flush() + raise SystemExit(3) +print("HELD %d" % os.getpid(), flush=True) +time.sleep(@@HOLD_SECONDS@@) +handle.release() +'''.replace("@@HOLD_SECONDS@@", str(_CHILD_HOLD_SECONDS)) + + +def _pump_lines(stream: "IO[str]", sink: "queue.Queue[str]") -> None: + """后台泵:把子进程 stdout 逐行投进队列,流结束投 EOF 哨兵。 + + 为什么不用 `communicate(timeout=...)`:它要等**进程退出**才返回,而子进程故意持有 30s, + 必然先超时;而裸 `readline()` 在子进程卡死时会永久阻塞。队列 + 上界是唯一两头都咬住的做法。 + """ + try: + for line in stream: + sink.put(line) + finally: + sink.put(_CHILD_EOF) + + +@dataclass +class _ChildProcess: + """真子进程句柄:等待带上界,收尸幂等(`finally` 之后仍可再调 `stop()`)。""" + + proc: "subprocess.Popen[str]" + lines: "queue.Queue[str]" + pump: "threading.Thread" + captured: List[str] = field(default_factory=list) + + def await_held(self, timeout: float = _HELD_TIMEOUT) -> int: + """等到 `HELD `,返回子进程 pid;超时或子进程早退都抛带 stdout/stderr 原文的断言。""" + deadline = time.monotonic() + timeout + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise AssertionError(self.describe(f"子进程 {timeout:.0f}s 内未打印 HELD")) + try: + line = self.lines.get(timeout=remaining) + except queue.Empty: + raise AssertionError(self.describe(f"子进程 {timeout:.0f}s 内未打印 HELD")) from None + if line == _CHILD_EOF: + raise AssertionError(self.describe("子进程未打印 HELD 就退出了")) + self.captured.append(line) + if line.startswith("HELD "): + return int(line.split()[1]) + + def stop(self) -> None: + """terminate → wait → kill 兜底。幂等:进程已退出时只剩 `join`。""" + if self.proc.poll() is None: + # `poll()` 判活与 `terminate()` 之间存在窗口:子进程可能刚好在此间退出, + # Windows 上 `terminate()` 会抛 OSError/PermissionError。若在 `finally` 里抛出, + # 会顶掉真正的 AssertionError(「子进程未打印 HELD」),故这里的收尸异常一律吞掉。 + with contextlib.suppress(OSError): + self.proc.terminate() + try: + self.proc.wait(timeout=_TERMINATE_TIMEOUT) + except subprocess.TimeoutExpired: + self.proc.kill() + self.proc.wait(timeout=_TERMINATE_TIMEOUT) + self.pump.join(timeout=_TERMINATE_TIMEOUT) + if self.pump.is_alive(): + # join 超时不能静默丢弃:否则后续排空队列可能少行,describe() 消息残缺却不说。 + self.captured.append("") + + def collect_output(self) -> Tuple[str, str]: + """收尸并返回子进程 stdout/stderr 原文(`stop()` 之后 stderr 已到 EOF,读不会阻塞)。""" + self.stop() + while True: + try: + line = self.lines.get_nowait() + except queue.Empty: + break + if line != _CHILD_EOF: + self.captured.append(line) + stderr_text = "" + if self.proc.stderr is not None: + stderr_text = self.proc.stderr.read() or "" + return "".join(self.captured), stderr_text + + def describe(self, reason: str) -> str: + """把失败原因和子进程原文拼成一条消息,并顺手收尸(断言失败也不留僵尸)。""" + stdout_text, stderr_text = self.collect_output() + return f"{reason}\n--- child stdout ---\n{stdout_text}\n--- child stderr ---\n{stderr_text}" + + +def _spawn_child(tmp_path: Path) -> _ChildProcess: + """起真子进程跑内联脚本;`cwd` + `PYTHONPATH` 双写,保证它 import 到**本仓库**的代码。""" + repo_root = Path(__file__).resolve().parents[1] + env = dict(os.environ) + inherited = env.get("PYTHONPATH") + env["PYTHONPATH"] = os.pathsep.join([str(repo_root)] + ([inherited] if inherited else [])) + # 子进程只 print ASCII,但显式钉住编码,免得继承到奇怪 locale 时 print 抛 UnicodeError。 + env["PYTHONIOENCODING"] = "utf-8" + proc = subprocess.Popen( + [sys.executable, "-c", _CHILD_SCRIPT, str(tmp_path)], + cwd=str(repo_root), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + ) + lines: "queue.Queue[str]" = queue.Queue() + if proc.stdout is None: # stdout=PIPE 保证不为 None;显式收窄给类型检查器看 + raise AssertionError("stdout=PIPE 却拿到 None") + pump = threading.Thread(target=_pump_lines, args=(proc.stdout, lines), daemon=True) + pump.start() + return _ChildProcess(proc=proc, lines=lines, pump=pump) + + +def test_subprocess_holder_blocks_parent_and_os_release_heals(tmp_path: Path) -> None: + """真子进程持有锁 ⇒ 父进程被挡住且读到子进程身份;子进程被杀 ⇒ 父进程立刻能取锁。 + + 变异自证(计划 Task 3 Step 2):把 `_acquire_os_lock` 改成直接 `return True`(跳过 OS 锁)后, + 本用例必然转红 —— 那时父进程也能「取到」锁,`blocked` 会是 `LockHandle` 而不是子进程的记录。 + """ + paths = _paths(tmp_path) + child = _spawn_child(tmp_path) + try: + child_pid = child.await_held() + + blocked = try_acquire(paths, _record(tmp_path, port=8002)) + if isinstance(blocked, LockHandle): + blocked.release() + raise AssertionError( + child.describe("子进程持有锁时父进程必须被挡住(返回 LockRecord),实际又拿到了锁句柄") + ) + assert blocked.pid == child_pid, child.describe( + f"返回的持有者 pid 必须是子进程 pid {child_pid},实际 {blocked.pid}" + ) + # 记录必须来自子进程**回写**的 meta(父进程的候选记录是 port=8002),否则 pid 巧合也对不上语义。 + assert blocked.port == 8000, child.describe("持有者记录必须来自子进程回写的 meta(port=8000)") + + child.stop() + + healed = try_acquire(paths, _record(tmp_path, port=8000)) + if not isinstance(healed, LockHandle): + raise AssertionError( + child.describe(f"子进程退出后父进程必须能取锁,实际拿到 {type(healed).__name__}") + ) + try: + # 反向自证:降级句柄(`_fd == -1`)同样是 `LockHandle`,单靠 `isinstance` 无法区分 + # 「真拿到锁」与「降级放过」。故用另一条 record 再抢一次:真锁必须挡住本进程自己, + # 且返回的持有者 pid 必须是本进程 —— 这才能证明 `healed` 是真锁而非降级句柄。 + contender = try_acquire(paths, _record(tmp_path, port=8003)) + if isinstance(contender, LockHandle): + contender.release() + raise AssertionError( + child.describe("healed 必须是真的锁:第二条 record 竟也取到锁句柄(说明 healed 只是降级放过)") + ) + assert contender.pid == os.getpid(), child.describe( + f"持锁者应为本进程 pid {os.getpid()},实际 {contender.pid}" + ) + finally: + healed.release() + finally: + child.stop() diff --git a/tests/test_server_lan.py b/tests/test_server_lan.py index b465954..896fe3a 100644 --- a/tests/test_server_lan.py +++ b/tests/test_server_lan.py @@ -109,3 +109,44 @@ def test_wb_lan_info_does_not_leak_sync_key(lan_client, client): r = lan_client.get("/api/wb/lan-info") assert "X-WB-Key" not in r.text assert client.get("/api/wb/state/key").status_code == 200 + + +# ── lan-info 的 port 必须来自真实监听端口(Task 8:端口顺延后手机才拿得到对地址)── + + +def test_wb_lan_info_reports_the_real_listening_port(client): + """`build_server` 写入 `app.state.server_port` ⇒ lan-info 报的是**那个**端口,不是写死的 8000。 + + 端口的唯一真相在 `build_server`(顺延由 `claim_instance` 决定)。写死 8000 的后果不是 + "少个字段":手机端会按错端口去连、超时后提示"配对失败",而主机日志里一切正常。 + """ + sentinel = 8007 + setattr(client.app.state, "server_port", sentinel) + try: + body = client.get("/api/wb/lan-info").json() + finally: + delattr(client.app.state, "server_port") + assert body["port"] == sentinel, f"必须报真实监听端口,实际 {body['port']}" + + +def test_wb_lan_info_falls_back_to_8000_when_port_unknown(client): + """没有 `server_port` 时退回 8000:TestClient / Android 的既有行为不得改变。""" + client.app.state.__dict__.pop("server_port", None) + body = client.get("/api/wb/lan-info").json() + assert body["port"] == 8000, f"缺省必须是 8000,实际 {body['port']}" + + +def test_build_server_records_port_on_app_state(): + """`build_server` 必须把入参端口挂到 `app.state.server_port`(上面两条的前提)。 + + 只断言端点读 state 而不断言写入方,等于把"谁来写"留给下一任猜:没人写 ⇒ 端点永远 + 退回 8000,而端点的两条用例(手动 setattr)照样全绿。 + """ + from delector.core.server_lifecycle import build_server + from delector.server import app + + build_server("127.0.0.1", 8007, app) + try: + assert app.state.server_port == 8007 + finally: + delattr(app.state, "server_port") diff --git a/tests/test_start.py b/tests/test_start.py index 43d8256..f1732ef 100644 --- a/tests/test_start.py +++ b/tests/test_start.py @@ -1,6 +1,28 @@ -"""Task1 Step3: 平台绑定行为测试 — Android vs Desktop get_bind_host()""" +r"""Task1 Step3: 平台绑定行为测试 — Android vs Desktop get_bind_host() + +Task4 追加:`claim_instance` —— 单实例判定的**唯一**入口(spec 2026-10-21 §3.2)。 + +为什么这组用例一律把锁落点钉到 `tmp_path` +---------------------------------------- +`claim_instance` 会**真去取锁**(`instance_lock.try_acquire`),而桌面默认落点是 +`%LOCALAPPDATA%\DeLector`。不注入落点就等于:跑一次测试在本机的真实数据目录里留下一把 +锁与一份 `instance.json`,并让后续用例(甚至用户自己的实例)被误判成「已有实例在运行」。 +故每条用例都经 `_pin_lock_dir` 把 `LockPaths.for_platform` 指向 `tmp_path`。 + +同理:任何拿到的 `Ready.lock` 都 MUST 在 finally 里 `release()` —— 锁是文件级的, +同进程的下一条用例会真的撞上它(那是假红,不是缺陷)。 +""" + +import logging +import sys +import types +from pathlib import Path + +import pytest import start +from delector.core import instance_lock +from delector.core.version import APP_VERSION def test_get_bind_host_on_android(monkeypatch): @@ -24,5 +46,742 @@ def test_get_bind_host_android_loopback_only(monkeypatch): def test_get_bind_host_desktop_allows_lan(monkeypatch): """桌面端有意绑 0.0.0.0 以允许同 Wi-Fi 设备访问文章。""" monkeypatch.setattr(start, "is_android", lambda: False) - host = start.get_bind_host() - assert host == "0.0.0.0" + assert start.get_bind_host() == "0.0.0.0" + + +# ── Task 4:claim_instance 单一判定点 ──────────────────────────────────────────── +def _pin_lock_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> instance_lock.LockPaths: + """把锁落点定到 `tmp_path`,并返回这份落点(供断言「锁有没有被释放」时复用)。 + + 为什么 patch 的是 `instance_lock.LockPaths.for_platform` 而不是 `start.LockPaths`: + `start` 拿到的是**同一个类对象**,在类上换方法对一切引用方生效;将来实现改回 + `from ... import LockPaths` 或改成 `instance_lock.LockPaths(...)` 都不影响本注入点。 + """ + paths = instance_lock.LockPaths( + lock=str(tmp_path / "instance.lock"), + meta=str(tmp_path / "instance.json"), + ) + # 调用点在被测模块 start.py::claim_instance(**调用期**经 `LockPaths.for_platform(env)` + # 做类属性查找),故本补丁即时生效;instance_lock 自身不调用它。 + # mock-drift-ok: 消费方是 start.py::claim_instance(同进程调用期类属性查找),补丁即时生效 + monkeypatch.setattr(instance_lock.LockPaths, "for_platform", staticmethod(lambda env: paths)) + return paths + + +def _hold_lock( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + version: str, + data_dir: str, + pid: int = 424242, + port: int = 8004, +) -> tuple[instance_lock.LockPaths, instance_lock.LockHandle]: + """模拟「另一个实例已持锁」,并让它把身份(版本 / 数据目录)写进 meta。 + + 用**真锁**而不是 patch `try_acquire`:Windows 的字节区间锁对同进程的另一个 fd 同样 + 冲突(实测 `msvcrt.locking` 返回 EACCES ⇒ `try_acquire` 返回对方 `LockRecord`), + 所以这里能把「抢锁失败 → 读对方身份」整条真实链路验掉,不必伪造。 + """ + paths = _pin_lock_dir(tmp_path, monkeypatch) + holder = instance_lock.try_acquire( + paths, + instance_lock.LockRecord( + pid=pid, + version=version, + port=port, + data_dir=data_dir, + acquired_at="2026-10-21T00:00:00", + ), + ) + assert isinstance(holder, instance_lock.LockHandle), "前提失败:模拟实例没能拿到锁" + return paths, holder + + +def test_claim_instance_ready_on_free_8000(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """8000 空闲 ⇒ `Ready(8000)`,且回写后的 `record.port` 必须是真端口而非占位的 0。""" + _pin_lock_dir(tmp_path, monkeypatch) + monkeypatch.setattr(start, "is_port_in_use", lambda port: False) + + result = start.claim_instance({"DELECTOR_DATA_DIR": str(tmp_path / "data")}) + + assert isinstance(result, start.Ready), f"锁空闲时应拿到 Ready,实际 {result!r}" + try: + assert result.port == 8000 + assert result.lock.record.port == 8000, "Ready.lock.record.port 必须是选定端口(抢锁时只能先写 0)" + finally: + result.lock.release() + + +def test_claim_instance_uses_patched_probe_not_a_welded_default( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """`monkeypatch start.is_port_in_use` 必须真生效:钉住「probe 默认值在函数体内求值」。 + + 这条用例是**变异自证**过的(旧版是死绿):判据必须让「patch 生效」与「默认参数焊死」 + 两条路径**必然分叉**,否则两者结果相同、无论实现怎么写都绿。 + 做法:patch 后只声明 8000 被占 ⇒ 生效时顺延到 8002;焊死时(用真实探测)本机 8000 空闲 + ⇒ 返回 8000 ⇒ 断言立刻转红。旧版只 patch 成「全部空闲」,焊死路径同样返回 8000,故恒绿。 + """ + _pin_lock_dir(tmp_path, monkeypatch) + monkeypatch.setattr(start, "is_port_in_use", lambda port: port == 8000) + + result = start.claim_instance({"DELECTOR_DATA_DIR": str(tmp_path / "data")}) + + assert isinstance(result, start.Ready), f"8002 应可用,实际 {result!r}" + try: + assert result.port == 8002, ( + "patch 未生效(probe 被默认参数焊死)⇒ 会拿到真实探测的 8000;" + f"期望顺延到 8002,实际 {result.port}" + ) + assert result.lock.record.port == 8002 + assert result.port != start.SUPERVISOR_RESERVED_PORT + finally: + result.lock.release() + + +def test_claim_instance_skips_busy_ports_and_never_picks_reserved_8001( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +): + """8000 与 8001 被占 ⇒ 顺延到 8002;**永不**选 8001(哨兵),且顺延必须留痕。""" + assert 8001 not in start.PORT_CANDIDATES, ( + "8001 是 Go supervisor 的保留端口(agent/internal/pythonsvc/doc.go:9-10)," + "不得进入候选:抢到它等于和 supervisor 抢同一个端口" + ) + _pin_lock_dir(tmp_path, monkeypatch) + busy = {8000, 8001} + + result = start.claim_instance( + {"DELECTOR_DATA_DIR": str(tmp_path / "data")}, + probe=lambda port: port in busy, + ) + + assert isinstance(result, start.Ready), f"还有空闲候选时应拿到 Ready,实际 {result!r}" + try: + assert result.port == 8002 + assert result.port != 8001 + assert result.lock.record.port == 8002 + err = capsys.readouterr().err + assert "8000" in err and "8002" in err, ( + f"顺延必须写一条含新旧端口的日志(No Silent Fallback),实际 stderr={err!r}" + ) + finally: + result.lock.release() + + +def test_claim_instance_reports_no_free_port_and_releases_lock( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """8000–8010 全占 ⇒ `no_free_port`,且**必须已释放锁**(否则后续启动全被判「已在运行」)。""" + paths = _pin_lock_dir(tmp_path, monkeypatch) + + result = start.claim_instance({"DELECTOR_DATA_DIR": str(tmp_path / "data")}, probe=lambda port: True) + + assert isinstance(result, start.AlreadyRunning), f"无端口可选时应是 AlreadyRunning,实际 {result!r}" + assert result.reason == "no_free_port" + assert result.record == instance_lock.UNKNOWN_RECORD, "这种情况没有可归因的对方实例 ⇒ 只能是 unknown 记录" + + reacquired = instance_lock.try_acquire(paths, instance_lock.UNKNOWN_RECORD) + assert isinstance(reacquired, instance_lock.LockHandle), ( + "放弃端口时必须释放锁:残留的锁会让下一次启动(以及同进程的下一条用例)被误判成「已有实例在运行」" + ) + reacquired.release() + + +def test_claim_instance_reports_version_mismatch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """锁被持有且版本不同 ⇒ `version_mismatch`,回执带**对方**版本(上层据此提示升级)。""" + data_dir = str(tmp_path / "data") + _, holder = _hold_lock(tmp_path, monkeypatch, version="0.9.9", data_dir=data_dir) + probed: list[int] = [] + + def probe(port: int) -> bool: + probed.append(port) + return False + + try: + result = start.claim_instance({"DELECTOR_DATA_DIR": data_dir}, probe=probe) + + assert isinstance(result, start.AlreadyRunning), f"锁被持有时应是 AlreadyRunning,实际 {result!r}" + assert result.reason == "version_mismatch" + assert result.record.version == "0.9.9", "回执必须带对方版本,否则用户看不到「该升级了」" + assert probed == [], "锁都没抢到就去探端口:探测本身有意义,但顺序必须是先锁后探(计划 GREEN 明确要求)" + finally: + holder.release() + + +def test_claim_instance_reports_data_dir_mismatch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """锁被持有、版本相同但数据目录不同 ⇒ `data_dir_mismatch`(另一份库的实例在跑)。""" + data_dir = str(tmp_path / "data") + _, holder = _hold_lock(tmp_path, monkeypatch, version=APP_VERSION, data_dir=str(tmp_path / "other")) + try: + result = start.claim_instance({"DELECTOR_DATA_DIR": data_dir}, probe=lambda port: False) + + assert isinstance(result, start.AlreadyRunning), f"锁被持有时应是 AlreadyRunning,实际 {result!r}" + assert result.reason == "data_dir_mismatch" + assert result.record.data_dir == str(tmp_path / "other") + finally: + holder.release() + + +def test_claim_instance_requested_port_busy_does_not_fall_through( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """`requested_port=9000` 且被占 ⇒ `port_busy`,**不**落到 9001(显式指定就是唯一目标)。""" + paths = _pin_lock_dir(tmp_path, monkeypatch) + probed: list[int] = [] + + def probe(port: int) -> bool: + probed.append(port) + return port == 9000 + + result = start.claim_instance( + {"DELECTOR_DATA_DIR": str(tmp_path / "data")}, + requested_port=9000, + probe=probe, + ) + + assert isinstance(result, start.AlreadyRunning), f"指定端口被占时应是 AlreadyRunning,实际 {result!r}" + assert result.reason == "port_busy" + assert probed == [9000], f"requested_port 给定时只许试它,绝不能顺延(实际探测 {probed})" + + reacquired = instance_lock.try_acquire(paths, instance_lock.UNKNOWN_RECORD) + assert isinstance(reacquired, instance_lock.LockHandle), "port_busy 早退同样必须释放锁" + reacquired.release() + + +def test_claim_instance_rejects_explicit_reserved_8001(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """显式 `requested_port=8001` ⇒ `port_busy`:8001 是 supervisor 的监听点,永远不许选。 + + `PORT_CANDIDATES` 不含 8001 只挡住**自动顺延**;显式传参走的是另一条路径,必须同样过闸。 + 且 8001 对本进程恒不可用(与此刻有没有人在听无关)⇒ 连 probe 都不该调。 + """ + paths = _pin_lock_dir(tmp_path, monkeypatch) + probed: list[int] = [] + + def probe(port: int) -> bool: + probed.append(port) + return False # 空闲也不许要 + + result = start.claim_instance( + {"DELECTOR_DATA_DIR": str(tmp_path / "data")}, + requested_port=start.SUPERVISOR_RESERVED_PORT, + probe=probe, + ) + + assert isinstance(result, start.AlreadyRunning), f"保留端口应被判不可用,实际 {result!r}" + assert result.reason == "port_busy" + assert probed == [], f"保留端口与探测结果无关,不该去 probe(实际探测 {probed})" + + reacquired = instance_lock.try_acquire(paths, instance_lock.UNKNOWN_RECORD) + assert isinstance(reacquired, instance_lock.LockHandle), "保留端口早退同样必须释放锁" + reacquired.release() + + +def test_claim_instance_unknown_when_holder_has_no_pid(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """`pid == 0` 的持有者记录(meta 缺失 / 半截)⇒ `unknown`,且**优先级**高于版本比较。 + + 反证方式:这条记录同时把版本与数据目录都写成「不同」—— 若实现先比版本,会得出 + `version_mismatch`;那个结论是假的(占位值不携带任何信息),用户照着「请升级对方」 + 升级完依旧起不来。 + """ + data_dir = str(tmp_path / "data") + _, holder = _hold_lock( + tmp_path, monkeypatch, version="0.9.9", data_dir=str(tmp_path / "other"), pid=0 + ) + try: + result = start.claim_instance({"DELECTOR_DATA_DIR": data_dir}, probe=lambda port: False) + + assert isinstance(result, start.AlreadyRunning), f"锁被持有时应是 AlreadyRunning,实际 {result!r}" + assert result.reason == "unknown", ( + f"pid==0 必须最先归因 unknown(先比版本会得到假的 version_mismatch),实际 {result.reason}" + ) + finally: + holder.release() + + +def test_claim_instance_unknown_when_holder_port_not_written( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """端口未回写的中间态(`port == 0`)⇒ `unknown`,绝不归 `same`。 + + `claim_instance` 先以 `port=0` 抢锁、选定端口后才回写;别人恰好读到这一刻时 pid / 版本 / + 目录全都对得上,但端口还是 0 —— 归 `same` 会让上层拼出 `http://127.0.0.1:0`。 + 这里版本与目录**完全相同**,唯一能挡住 `same` 的就是端口判据。 + """ + data_dir = str(tmp_path / "data") + _, holder = _hold_lock(tmp_path, monkeypatch, version=APP_VERSION, data_dir=data_dir, port=0) + try: + result = start.claim_instance({"DELECTOR_DATA_DIR": data_dir}, probe=lambda port: False) + + assert isinstance(result, start.AlreadyRunning), f"锁被持有时应是 AlreadyRunning,实际 {result!r}" + assert result.reason == "unknown", ( + f"端口未回写必须归 unknown(否则上层会拼出 :0 的地址),实际 {result.reason}" + ) + finally: + holder.release() + + +def test_claim_instance_reports_same_instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """同版本、同数据目录 ⇒ `same`,回执带对方端口(上层据此直接打开已有页面)。""" + data_dir = str(tmp_path / "data") + _, holder = _hold_lock(tmp_path, monkeypatch, version=APP_VERSION, data_dir=data_dir) + try: + result = start.claim_instance({"DELECTOR_DATA_DIR": data_dir}, probe=lambda port: False) + + assert isinstance(result, start.AlreadyRunning), f"锁被持有时应是 AlreadyRunning,实际 {result!r}" + assert result.reason == "same" + assert result.record.port == 8004, "same 时上层要拿对方端口去开页面" + finally: + holder.release() + + +def test_claim_instance_treats_equivalent_paths_as_same(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """`/data/../data` 与 `/data` 是同一目录 ⇒ `same`,不是 `data_dir_mismatch`。 + + 纯字符串比较会把同一份库误判成「另一份库的实例在跑」,用户于是关掉本该复用的那个实例。 + (`normcase` 的大小写折叠在 Windows 上同样归到这里:`D:\\X` 与 `d:\\x\\` 亦须判同。) + """ + data_dir = str(tmp_path / "data") + _, holder = _hold_lock( + tmp_path, monkeypatch, version=APP_VERSION, data_dir=str(tmp_path / "data" / ".." / "data") + ) + try: + result = start.claim_instance({"DELECTOR_DATA_DIR": data_dir}, probe=lambda port: False) + + assert isinstance(result, start.AlreadyRunning), f"锁被持有时应是 AlreadyRunning,实际 {result!r}" + assert result.reason == "same", ( + f"等价路径必须规范化后再比(实际 {result.reason},对方 data_dir={result.record.data_dir!r})" + ) + finally: + holder.release() + + +def test_claim_instance_on_android_never_takes_the_lock( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """Android 短路:`Ready(8000)` 且**不**创建任何锁文件(降级句柄 `is_degraded`)。 + + Android 的数据落点由宿主注入,本进程替它猜一个锁目录只会造出一把与真实实例无关的锁。 + """ + paths = _pin_lock_dir(tmp_path, monkeypatch) + monkeypatch.setattr(start, "is_android", lambda: True) + + result = start.claim_instance({"DELECTOR_DATA_DIR": str(tmp_path / "data")}) + + assert isinstance(result, start.Ready), f"Android 应直接给 Ready,实际 {result!r}" + assert result.port == start.PORT_CANDIDATES[0] + assert result.lock.is_degraded, "Android 必须是降级句柄(release() 为空操作)" + assert not Path(paths.lock).exists(), f"Android MUST NOT 创建锁文件:{paths.lock}" + + +def test_claim_instance_releases_lock_when_probe_raises( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """`probe` 抛异常(生产实现 fd 耗尽时抛 `OSError`)⇒ 异常穿出,但锁 MUST 已释放。 + + 不释放 ⇒ 一个已经失败的进程攥着锁 ⇒ 同进程后续 claim(以及用户重开)全被判「已在运行」。 + """ + + def boom(port: int) -> bool: + raise OSError("fd 耗尽") + + paths = _pin_lock_dir(tmp_path, monkeypatch) + + with pytest.raises(OSError): + start.claim_instance({"DELECTOR_DATA_DIR": str(tmp_path / "data")}, probe=boom) + + reacquired = instance_lock.try_acquire(paths, instance_lock.UNKNOWN_RECORD) + assert isinstance(reacquired, instance_lock.LockHandle), "probe 抛异常必须放锁,否则后续启动全是假红" + reacquired.release() + + +def test_claim_instance_notes_reach_logger_when_stderr_is_none( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +): + """`sys.stderr is None`(`pythonw` 桌面壳常态)时顺延提示 MUST 落到 logger,不得静默消失。 + + CPython 对 `print(..., file=None)` 是直接丢弃、不报错 ⇒ 只写 `print` 的实现在桌面端 100% + 丢失「端口被占用改用了别的端口」这条唯一的归因线索。 + """ + _pin_lock_dir(tmp_path, monkeypatch) + monkeypatch.setattr(start.sys, "stderr", None) + + with caplog.at_level(logging.WARNING, logger="delector"): + result = start.claim_instance( + {"DELECTOR_DATA_DIR": str(tmp_path / "data")}, probe=lambda port: port == 8000 + ) + + assert isinstance(result, start.Ready), f"8002 应可用,实际 {result!r}" + try: + assert "8000" in caplog.text and "8002" in caplog.text, ( + f"无 stderr 时顺延提示必须进 logger(No Silent Fallback),实际日志={caplog.text!r}" + ) + finally: + result.lock.release() + + +# ── Task 6:main() 接线(claim → 端口 → bootstrap → serve;finally 放锁)──────────── +def _patch_serving( + monkeypatch: pytest.MonkeyPatch, +) -> tuple[list[tuple[str, int]], types.SimpleNamespace]: + """把 uvicorn 装配链换成替身:既断言 `main()` 的接线,又不真起服务、不拉 spaCy。 + + 与 `tests/test_start_lifecycle.py` 同款手法(patch `server_lifecycle` 的四个入口 + + `sys.modules` 里放一个假 `delector.server`)—— 那里验的是生命周期语义,这里验的是 + 「claim 的结果有没有被真的接到 build_server / on_serving 上」。 + """ + from delector.core import server_lifecycle + + built: list[tuple[str, int]] = [] + fake_server = types.SimpleNamespace(should_exit=False) + + def _build(host: str, port: int, app: object) -> object: + built.append((host, port)) + return fake_server + + class _FinishedThread: + """替身线程:join 立即返回、is_alive 恒 False。""" + + daemon = False + + def join(self, *_args: object, **_kwargs: object) -> None: + return None + + def is_alive(self) -> bool: + return False + + monkeypatch.setitem(sys.modules, "delector.server", types.SimpleNamespace(app=object())) + monkeypatch.setattr(server_lifecycle, "build_server", _build) + monkeypatch.setattr(server_lifecycle, "serve_in_thread", lambda server: _FinishedThread()) + monkeypatch.setattr(server_lifecycle, "shutdown", lambda *_a, **_k: True) + return built, fake_server + + +def test_main_exits_without_importing_server_when_lock_held_by_other_version( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +): + """锁被另一版本持有 ⇒ 退出码 1、**不** import `delector.server`、**不**搬库、不写地址。 + + 为什么钉「不 import `delector.server`」而不是只钉退出码:那一行一跑,`database` 就按 + 当时的 env 把 DATA_DIR 落定,还会拉起 spaCy —— 一个注定要退出的进程没理由付这笔账, + 而且「判出已有实例 ⇒ 绝不动它的库」正是本设计的不变量。 + 实现方式:往 `sys.modules` 塞一个**属性访问即炸**的哨兵模块;`from delector.server + import app` 一旦执行就会撞上它(不是靠"恰好没导入"这种弱断言)。 + """ + + class _ServerSentinel(types.ModuleType): + def __getattr__(self, name: str) -> object: + raise AssertionError(f"判出「已在运行」时不得 import delector.server(触发了 {name})") + + data_dir = str(tmp_path / "data") + monkeypatch.setenv("DELECTOR_DATA_DIR", data_dir) + _, holder = _hold_lock(tmp_path, monkeypatch, version="0.0.1-legacy", data_dir=data_dir) + monkeypatch.setattr(start, "is_android", lambda: False) + monkeypatch.setattr(start, "open_browser", lambda port: pytest.fail("版本不同 ⇒ 不得为它打开页面")) + monkeypatch.setattr( + start, "bootstrap_data_dir", lambda *_a, **_k: pytest.fail("已有实例在跑时不得动它的库") + ) + monkeypatch.setitem(sys.modules, "delector.server", _ServerSentinel("delector.server")) + + try: + with pytest.raises(SystemExit) as excinfo: + start.main() + finally: + holder.release() + + assert excinfo.value.code == 1, f"实例冲突必须以退出码 1 结束,实际 {excinfo.value.code!r}" + captured = capsys.readouterr() + text = captured.out + captured.err + assert "0.0.1-legacy" in text and APP_VERSION in text, ( + f"文案必须同时给出双方版本,用户才知道该升级哪一个;实际输出={text!r}" + ) + assert "Traceback" not in text, f"面向用户的文案不得含堆栈:{text!r}" + + +def test_main_serves_on_fallback_port_when_8000_is_taken_by_foreign_program( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """8000 被无关程序占 ⇒ 服务必须建在 claim 选定的 8002 上(`on_serving` 也拿到 8002)。 + + 这里是**接线**断言:`Ready.port` 必须一路传到横幅 / 浏览器 / `build_server` / `on_serving`; + 任何一处仍写死 8000,用户/壳拿到的地址就与实际监听点不一致。 + """ + _pin_lock_dir(tmp_path, monkeypatch) + built, _ = _patch_serving(monkeypatch) + ready = start.Ready( + port=8002, + lock=instance_lock.LockHandle( + paths=instance_lock.LockPaths( + lock=str(tmp_path / "instance.lock"), meta=str(tmp_path / "instance.json") + ), + record=instance_lock.UNKNOWN_RECORD, + ), + ) + monkeypatch.setattr(start, "claim_instance", lambda *_a, **_k: ready) + monkeypatch.setattr(start, "is_android", lambda: True) # 只绑回环、且不起浏览器线程 + monkeypatch.setattr(start, "bootstrap_data_dir", lambda *_a, **_k: "") + served: list[int] = [] + + start.main(on_serving=served.append) + + assert served == [8002], f"on_serving 必须收到顺延后的端口,实际 {served}" + assert built == [("127.0.0.1", 8002)], f"build_server 必须建在顺延端口上,实际 {built}" + + +def test_main_opens_browser_for_same_instance_and_returns( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +): + """锁被**同一**实例持有 ⇒ 打印人话 + 打开对方端口,然后正常返回(不 sys.exit、不起服务)。""" + data_dir = str(tmp_path / "data") + monkeypatch.setenv("DELECTOR_DATA_DIR", data_dir) + _, holder = _hold_lock(tmp_path, monkeypatch, version=APP_VERSION, data_dir=data_dir, port=8006) + built, _ = _patch_serving(monkeypatch) + opened: list[int] = [] + monkeypatch.setattr(start, "is_android", lambda: False) + monkeypatch.setattr(start, "open_browser", lambda port: opened.append(port)) + # `same` 分支开页面前会用 `_identity_probe` 复核对方端口(ADR-0020 §6.4)。这里替身回「是 + # DeLector」:本用例钉的是「识别为同一实例 ⇒ 打开对方端口并返回」,不钉复核逻辑 + # (复核为假的分支由 `test_main_does_not_open_browser_when_same_instance_port_is_silent` 钉)。 + # 不 patch 就会去打真实的 127.0.0.1:8006(本机无监听)⇒ 判「无响应」⇒ 用例假红。 + monkeypatch.setattr(start, "_identity_probe", lambda port: True) + monkeypatch.setattr( + start, "bootstrap_data_dir", lambda *_a, **_k: pytest.fail("已有实例在跑时不得动它的库") + ) + + try: + start.main() # 不得抛 SystemExit(退出码 0 的语义就是"正常返回") + finally: + holder.release() + + assert opened == [8006], f"必须打开**对方**的端口(不是写死的 8000),实际 {opened}" + assert built == [], f"识别为同一实例时绝不能再建第二个 server,实际 {built}" + out = capsys.readouterr().out + assert "8006" in out and "424242" in out, f"人话里必须点名端口与 pid,实际输出={out!r}" + + +def test_main_reports_unavailable_ports_without_claiming_an_instance( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +): + """8000–8010 全被占 ⇒ 退出码 1,且文案说的是「端口不可用」而**不是**「已有实例在运行」。 + + 两件事必须区分:端口被陌生程序占住,跟「另一个 DeLector 在跑」完全无关;讲混了用户会去 + 关一个并不存在的窗口,而真正的元凶(别的软件)永远没被提到。 + """ + _pin_lock_dir(tmp_path, monkeypatch) + monkeypatch.setattr(start, "is_android", lambda: False) + monkeypatch.setattr(start, "is_port_in_use", lambda _port: True) + monkeypatch.setattr(start, "open_browser", lambda port: pytest.fail("端口不可用时不得打开页面")) + + with pytest.raises(SystemExit) as excinfo: + start.main() + + assert excinfo.value.code == 1, f"没有可用端口必须以退出码 1 结束,实际 {excinfo.value.code!r}" + captured = capsys.readouterr() + text = captured.out + captured.err + assert "8000" in text, f"文案必须点名被占的端口,实际 {text!r}" + assert "已在" not in text, f"端口不可用 ≠ 已有 DeLector 在运行,实际 {text!r}" + assert "端口不可用" in text, f"必须说清是哪一类失败,实际 {text!r}" + + +def test_main_releases_instance_lock_after_keyboard_interrupt( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """`main()` 因 Ctrl+C 返回后,锁 MUST 已释放(同进程可再次取到)。 + + 这条挡的是「同进程反复调 `main()`」的**假红**:不释放 ⇒ 第二次启动被判「已在运行」, + 所有后续用例连带变红,而缺陷其实只在一处。 + """ + from delector.core import server_lifecycle + + paths = _pin_lock_dir(tmp_path, monkeypatch) + _patch_serving(monkeypatch) + + class _InterruptOnJoin: + daemon = False + + def join(self, *_args: object, **_kwargs: object) -> None: + raise KeyboardInterrupt + + def is_alive(self) -> bool: + return False + + monkeypatch.setattr(server_lifecycle, "serve_in_thread", lambda server: _InterruptOnJoin()) + monkeypatch.setattr(start, "is_port_in_use", lambda _port: False) + # 刻意**不**patch `is_android`:Android 短路返回的是降级句柄(release 是空操作), + # 那样这条用例就永远绿、根本证明不了「finally 放锁」—— 必须先让本进程真的把锁攥在手里。 + monkeypatch.setattr(start, "is_android", lambda: False) + monkeypatch.setattr(start, "open_browser", lambda port: None) + monkeypatch.setattr(start, "bootstrap_data_dir", lambda *_a, **_k: "") + + start.main() + + assert Path(paths.meta).exists(), "前提失败:main() 没真取锁(meta 未被回写),本用例证明不了放锁" + reacquired = instance_lock.try_acquire(paths, instance_lock.UNKNOWN_RECORD) + assert isinstance(reacquired, instance_lock.LockHandle), ( + "main() 退出路径(含 KeyboardInterrupt)MUST 在 finally 放锁,否则同进程二次启动全是假红" + ) + reacquired.release() + + +def test_claim_instance_identity_probe_on_degraded_lock_reports_same( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """锁降级(`LockHandle.is_degraded`)时,被占的候选端口若探到 DeLector ⇒ 判 `same`,**绝不顺延**。 + + 锁降级的成因(只读目录 / 权限 / 无原语)恰恰也是「另一个 DeLector 抢不到锁」的成因 ⇒ + 此时"候选端口被占"不等于"陌生程序":直接顺延就会起**第二个服务**,正是本计划要消灭的缺陷。 + """ + _pin_lock_dir(tmp_path, monkeypatch) + monkeypatch.setattr(instance_lock, "_acquire_os_lock", lambda fd: None) # 原语不可用 ⇒ 降级句柄 + probed: list[int] = [] + + def identity(port: int) -> bool: + probed.append(port) + return port == 8000 + + result = start.claim_instance( + {"DELECTOR_DATA_DIR": str(tmp_path / "data")}, + probe=lambda port: port == 8000, + identity_probe=identity, + ) + + assert isinstance(result, start.AlreadyRunning), f"8000 上是 DeLector ⇒ 不该顺延,实际 {result!r}" + assert result.reason == "same", f"线上已探到 DeLector 身份 ⇒ 应为 same,实际 {result.reason!r}" + assert result.record.port == 8000, "必须把探到的端口填进 record,上层才能打开已有页面" + assert probed == [8000], f"只该探**被占**的候选端口,实际 {probed}" + + +def test_claim_instance_skips_identity_probe_when_lock_is_real( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """拿到**真锁**时 MUST NOT 调身份探测:锁是唯一判据,探测只是第二腿。 + + 反证:「锁在我手里 ⇒ 8000 上的不是我」是硬事实;此时再去看 8000 上是谁,等于给 + 陌生程序一个冒充 DeLector 的机会(也让启动白白多付一次 HTTP 往返)。 + """ + _pin_lock_dir(tmp_path, monkeypatch) + + def identity(port: int) -> bool: + # 用 raise 而不是 `pytest.fail`:后者在 typeshed 里的返回类型不是 NoReturn, + # 会让 `-> bool` 撞上 mypy 的 missing-return(这里断言失败与抛异常等价)。 + raise AssertionError(f"拿到真锁时不得调身份探测(锁是唯一判据),却探了 {port}") + + result = start.claim_instance( + {"DELECTOR_DATA_DIR": str(tmp_path / "data")}, + probe=lambda port: True, + identity_probe=identity, + ) + + assert isinstance(result, start.AlreadyRunning), f"全部候选被占 ⇒ AlreadyRunning,实际 {result!r}" + assert result.reason == "no_free_port" + + +# ── 返修轮:main() 级身份探测接线 ──────────────────────────────────────────────── +def test_main_consults_identity_probe_when_instance_lock_is_degraded( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +): + """锁降级 ⇒ `main()` MUST 把 `_identity_probe` 交给 `claim_instance`。 + + 为什么这条必须是 `main()` 级:`claim_instance(..., identity_probe=...)` 的实参写在 `main()` + 里,`claim_instance` 自己的用例(`test_claim_instance_identity_probe_on_degraded_lock_reports_same`) + 是直接传参的 —— 它钉得住 `claim_instance` 的语义,**钉不住 `main()` 有没有把这条腿接上**。 + 删掉那个 kwarg,那些用例全绿,而线上行为退化成:锁降级时 8000 上的 DeLector 不被识别 ⇒ + 顺延到 8002 ⇒ 两个进程同时改同一个库。 + + 现场(锁降级 + 8000 上有 DeLector): + * `instance_lock._acquire_os_lock` ⇒ `None`(原语缺失 / 加锁被拒 ⇒ 降级句柄) + * `is_port_in_use` 只对 8000 为真(其余候选空闲) + * `_identity_probe` 只对 8000 为真(线上真有一个 DeLector 在跑) + 变异自证:删掉 `identity_probe=_identity_probe` ⇒ 8000 被当「陌生程序占端口」而顺延到 8002 + ⇒ 走进 `bootstrap_data_dir`(此处被 patch 成 `pytest.fail`)⇒ 本用例转红。 + """ + _pin_lock_dir(tmp_path, monkeypatch) + monkeypatch.setenv("DELECTOR_DATA_DIR", str(tmp_path / "data")) + monkeypatch.setattr(instance_lock, "_acquire_os_lock", lambda fd: None) # 降级句柄 + monkeypatch.setattr(start, "is_port_in_use", lambda port: port == 8000) + monkeypatch.setattr(start, "_identity_probe", lambda port: port == 8000) + built, _ = _patch_serving(monkeypatch) + opened: list[int] = [] + monkeypatch.setattr(start, "is_android", lambda: False) + monkeypatch.setattr(start, "open_browser", lambda port: opened.append(port)) + monkeypatch.setattr( + start, "bootstrap_data_dir", lambda *_a, **_k: pytest.fail("已在运行 ⇒ 不得动库") + ) + + start.main() # 不得抛 SystemExit:识别为既有实例是正常返回 + + assert built == [], f"已在运行 ⇒ 绝不能再建第二个 server(那正是本用例要消灭的双实例),实际 {built}" + assert opened == [8000], f"应打开线上探到的 8000(不是顺延算出来的端口),实际 {opened}" + out = capsys.readouterr().out + assert "已在运行" in out, f"必须告诉用户已有实例在跑,实际输出={out!r}" + + +def test_main_does_not_open_browser_when_same_instance_port_is_silent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +): + """`same` 但对方端口不响应 `/api/health` ⇒ 提示 + **不**开页面。 + + 不变式(ADR-0020 §6.4):绝不为一个不响应 `/api/health` 的端口打开页面。`record` 可能来自 + 上一次运行留下的陈旧 meta(`write_record` 失败只 WARNING),此时 `record.port` 上未必真有 + DeLector —— 照旧开页面等于把用户扔到一个打不开的地址,且他无从判断「到底在不在跑」。 + """ + data_dir = str(tmp_path / "data") + monkeypatch.setenv("DELECTOR_DATA_DIR", data_dir) + _, holder = _hold_lock(tmp_path, monkeypatch, version=APP_VERSION, data_dir=data_dir, port=8006) + built, _ = _patch_serving(monkeypatch) + opened: list[int] = [] + monkeypatch.setattr(start, "is_android", lambda: False) + monkeypatch.setattr(start, "open_browser", lambda port: opened.append(port)) + monkeypatch.setattr(start, "_identity_probe", lambda port: False) # 该端口上没人应答 + monkeypatch.setattr( + start, "bootstrap_data_dir", lambda *_a, **_k: pytest.fail("已有实例在跑时不得动它的库") + ) + + try: + start.main() # 不得起第二个服务,也不得 SystemExit(暂态,退出码仍是 0) + finally: + holder.release() + + assert opened == [], f"端口不响应 /api/health 时绝不开页面,实际 {opened}" + assert built == [], f"识别为同一实例时不能再建 server,实际 {built}" + out = capsys.readouterr().out + assert "已在运行" in out and "无响应" in out, ( + f"必须说清「在跑但该端口无响应」,而不是静默或只开页面,实际输出={out!r}" + ) + + +# ── 黄卡 Y1:`_fail` / `_say` 的「stdout 为 None」分支回归锁 ───────────────────── +def test_fail_logs_when_stdout_is_none( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +): + """`sys.stdout is None`(`pythonw`/窗口化便携版常态)时 `_fail` MUST 退到 logger 并退出码 1。 + + 若被「简化」成裸 `print(message)`,`sys.stdout is None` 下 `print` 是静默 no-op + (CPython 对 `file=None` 直接丢弃、不报错)⇒ 窗口化版本所有启动失败回到「双击没反应」, + 而 capsys 下 stdout 永不为 None,既有用例全部照绿 —— 故须显式 patch 成 None 钉住。 + """ + monkeypatch.setattr(start.sys, "stdout", None) + monkeypatch.setattr(start.sys, "stderr", None) # 与 pythonw 一致:两条流都收口 + + with caplog.at_level(logging.WARNING, logger="delector"): + with pytest.raises(SystemExit) as excinfo: + start._fail("[错误] 端口不可用") + + assert excinfo.value.code == 1, f"启动失败必须退出码 1,实际 {excinfo.value.code!r}" + assert "[错误] 端口不可用" in caplog.text, ( + f"无 stdout 时失败文案必须进 logger(No Silent Failure),实际日志={caplog.text!r}" + ) + + +def test_say_logs_when_stdout_is_none( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +): + """`sys.stdout is None` 时 `_say` MUST 退到 logger,不抛异常、不静默丢弃提示。""" + monkeypatch.setattr(start.sys, "stdout", None) + monkeypatch.setattr(start.sys, "stderr", None) + + with caplog.at_level(logging.WARNING, logger="delector"): + start._say("[提示] 已在运行") # 不得抛异常 + + assert "[提示] 已在运行" in caplog.text, ( + f"无 stdout 时用户提示必须进 logger,实际日志={caplog.text!r}" + ) diff --git a/tests/test_start_lifecycle.py b/tests/test_start_lifecycle.py index 546a78d..eb55af4 100644 --- a/tests/test_start_lifecycle.py +++ b/tests/test_start_lifecycle.py @@ -10,9 +10,11 @@ * 真起 uvicorn(最小 FastAPI app)+ 动态空闲端口,绝不碰生产 8000。 """ +import os import socket import threading import time +from pathlib import Path from typing import Any, List, Tuple import pytest @@ -189,14 +191,43 @@ def test_await_ready_false_when_nothing_on_port(): # ── start.main:端口被占时不再盲复用 ────────────────────────────────────────── -def test_main_exits_when_port_held_by_foreign_program(monkeypatch, capsys): +def _pin_lock_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Any: + """把锁落点钉到 `tmp_path`(理由同 `tests/test_start.py` 的同名助手)。 + + `start.main()` 现在会**真去取锁**(`claim_instance`),不钉落点就等于:跑一次测试往本机 + 真实的 `%LOCALAPPDATA%\\DeLector` 里写一把锁与一份 `instance.json`,并让结果取决于 + 「开发机上此刻是否真有 DeLector 在跑」—— 那是随机器漂移的假红/假绿。 + """ + from delector.core import instance_lock + + paths = instance_lock.LockPaths( + lock=str(tmp_path / "instance.lock"), + meta=str(tmp_path / "instance.json"), + ) + # 调用点在被测模块 start.py::claim_instance(**调用期**经 `LockPaths.for_platform(env)` + # 做类属性查找),故本补丁即时生效;instance_lock 自身不调用它。 + # mock-drift-ok: 消费方是 start.py::claim_instance(同进程调用期类属性查找),补丁即时生效 + monkeypatch.setattr(instance_lock.LockPaths, "for_platform", staticmethod(lambda env: paths)) + return paths + + +def test_main_exits_when_all_candidate_ports_are_busy(tmp_path, monkeypatch, capsys): + """全部候选端口被占 ⇒ `no_free_port` ⇒ 退出码 1,且文案说的是「端口不可用」。 + + 这条**实际**验的语义(旧名 `test_main_exits_when_port_held_by_foreign_program` 名不符实): + 注入点只有 `is_port_in_use` ⇒ 8000–8010 全被占 ⇒ `claim_instance` 落 `no_free_port`。 + 它**不**验「端口上的占用者是谁」:本用例里真锁是拿到了的 ⇒ 锁是唯一判据 ⇒ `claim_instance` + 根本不做身份探测(那条链路由 `tests/test_start.py` 的 main 级用例钉住)。故原先注入 + `server_lifecycle.probe_identity` 是**死代码**(从来不会被调用),已删除。 + """ + _pin_lock_dir(tmp_path, monkeypatch) monkeypatch.setattr(start, "is_port_in_use", lambda _port: True) - monkeypatch.setattr("delector.core.server_lifecycle.probe_identity", lambda *_a, **_k: False) - monkeypatch.setattr(start, "open_browser", lambda _port: pytest.fail("不得为无关程序打开页面")) + monkeypatch.setattr(start, "open_browser", lambda _port: pytest.fail("端口不可用时不得打开页面")) - with pytest.raises(SystemExit): + with pytest.raises(SystemExit) as excinfo: start.main() + assert excinfo.value.code == 1, f"没有可用端口必须以退出码 1 结束,实际 {excinfo.value.code!r}" out = capsys.readouterr().out assert "8000" in out, f"报错必须点名端口,实际输出:{out!r}" # 否定断言:被**无关程序**占用时不得吐出「复用」语义文案(如「已在运行」)。只钉 @@ -206,14 +237,43 @@ def test_main_exits_when_port_held_by_foreign_program(monkeypatch, capsys): ) -def test_main_reuses_existing_delector_without_exiting(monkeypatch): +def test_main_reuses_existing_delector_without_exiting(tmp_path, monkeypatch): + """已有同版本、同数据目录的实例 ⇒ 打开对方页面并正常返回(不起第二个服务)。 + + Task 6 把判定点从「`is_port_in_use(8000)` 早退」换成 `claim_instance`:一个 DeLector 在跑 + 的**判据**自此是「锁被持有且身份相同」,而不是「8000 上有东西在听」。故本用例的**注入点** + 随之从 `is_port_in_use` 移到真锁(模拟另一个实例持锁),断言(开浏览器、不退出、不起服务) + 一字未动 —— 钉的仍是同一条产品行为。 + """ + from delector.core import instance_lock + from delector.core.data_dir_bootstrap import resolve_data_dir + from delector.core.version import APP_VERSION + + paths = _pin_lock_dir(tmp_path, monkeypatch) + holder = instance_lock.try_acquire( + paths, + instance_lock.LockRecord( + pid=4321, + version=APP_VERSION, + port=8000, + data_dir=resolve_data_dir(os.environ), + acquired_at="2026-10-21T00:00:00", + ), + ) + assert isinstance(holder, instance_lock.LockHandle), "前提失败:模拟实例没能拿到锁" + opened: List[int] = [] - monkeypatch.setattr(start, "is_port_in_use", lambda _port: True) - monkeypatch.setattr("delector.core.server_lifecycle.probe_identity", lambda *_a, **_k: True) monkeypatch.setattr(start, "is_android", lambda: False) monkeypatch.setattr(start, "open_browser", lambda port: opened.append(port)) + # `same` 分支现在会先用 `_identity_probe` 复核对方端口(ADR-0020 §6.4):这里替身回「是 + # DeLector」,本用例钉的仍是「识别为同一实例 ⇒ 开页面 + 正常返回」这条产品行为。 + # 不 patch 就会去真实探测 127.0.0.1:8000(本机无监听)⇒ 判「无响应」⇒ 假红。 + monkeypatch.setattr(start, "_identity_probe", lambda port: True) - start.main() # 识别为 DeLector:不得抛 SystemExit、不得起第二个服务 + try: + start.main() # 识别为同一实例:不得抛 SystemExit、不得起第二个服务 + finally: + holder.release() assert opened == [8000], "识别为 DeLector 时应保持既有行为:打开浏览器指向已有服务"