Skip to content

feat: desktop single-instance lock and port fallback - #172

Merged
ROM4n2 merged 16 commits into
masterfrom
feat/desktop-single-instance
Oct 22, 2026
Merged

ROM4n2 merged 16 commits into
masterfrom
feat/desktop-single-instance

Conversation

@ROM4n2

@ROM4n2 ROM4n2 commented Oct 22, 2026

Copy link
Copy Markdown
Owner

计划 docs/plans/2026-10-21-desktop-single-instance.md · 设计稿 docs/specs/2026-10-21-desktop-single-instance-design.md(APPROVED)。

为什么

同一台机器双击两次 = 两个进程抢同一个 SQLite 库;8000 被陌生程序占住 = 直接起不来。判据从「端口上有没有东西」换成 OS 文件锁。

做了什么

  • 锁与元数据分两个文件:instance.lock 只做 flock 目标、永不替换;instance.json 走临时文件 + os.replace。同一个文件 os.replace 会让 POSIX 锁指向已 unlink 的 inode ⇒ 锁静默失效。
  • 唯一判定点 start.claim_instance:start.main() 与桌面壳共用,归因优先级 unknown > version_mismatch > data_dir_mismatch > same。
  • 端口:候选 8000, 8002–8010(8001 是 Go supervisor 保留端口);自动顺延并留一条日志,显式 --port 不顺延。
  • 第二腿:锁降级时(只读目录 / 无锁原语)先对被占候选端口做 /api/health 身份探测 —— 否则会为同一个库起第二个服务。真锁在手一律不探测。
  • /api/wb/lan-info 报真实监听端口(此前写死 8000,顺延后手机拿到的是错地址)。
  • 桌面壳退出码 3 扩展到六种冲突情形,文案只含人话(版本 / pid / 端口 / 数据目录 / 日志路径)。

验证(本机实测)

  • 全量 pytest tests/ -q ⇒ 2584 passed + 2 skipped(基线 2517 + 2,净增 67)
  • ruff check . 零告警;mypy --strict delector tools / --follow-imports=skip tests / 入口面 --platform win32 --strict(4 files)均 0 issues
  • 六道机械闸全 exit 0(含 check_mock_drift 的 3 处已审豁免)
  • 真双启动手测:第二实例输出「DeLector 已在运行(v5.18.0,pid …, 端口 8000)」并 exit 0;杀进程后锁自愈(可重新取锁);8000 被占时输出「端口 8000 被占用,改用 8002」并服务在 8002,lan-info 同步报 8002

评审中挡下的两处缺陷

  1. try_acquire 原实现把任何 OSError 当「他人持有」⇒ NFS/SMB 上 ENOLCK 会被报成「已有实例在跑」且零 WARNING。
  2. 桌面壳「第二腿」接线曾无任何用例覆盖(删掉它 102 条全绿)⇒ 已补 main 级用例,删 kwarg 即红。

未做(见计划 Deferred Gap Register)

窗口提到前台(Win32 FindWindow);Go supervisor 加锁;is_port_in_use ~2s 探测成本;per-data-dir 多实例。

ROM4n2 added 16 commits October 22, 2026 00:12
…1 baseline

- architecture.md: top-level routes_*.py -> delector/routes/{a1,sync,rtc}.py,
  static/js module count 23 -> 36, signalling relay path
- ops.md: add working-convention #13 (three classes of false red: EOL noise,
  GBK stdout, exit-0-is-not-correct)
- PROJECT_OVERVIEW/work.log: pin the measured baseline (2517 passed + 2 skipped)
  and the re-onboard findings
…plan

Spec: global %LOCALAPPDATA%\DeLector\ lock (ADR-0019 6.4), port fallback
8000/8002-8010, identity probe demoted to second leg. Lock and metadata are
split into two files: POSIX flock binds the inode, so os.replace on the locked
file would silently void the lock.

Plan: 10 TDD tasks with subagent scaffolds and the deferred-gap register.
Lock and metadata are deliberately separate files: POSIX flock binds the inode,
so an os.replace on the locked file would leave the old fd guarding an unlinked
inode and silently void the lock. meta writes go through a same-dir temp file and
os.replace; reads tolerate missing/corrupt/BOM/missing-field payloads by
returning None instead of raising into the startup path.
Task 2 of docs/plans/2026-10-21-desktop-single-instance.md.

try_acquire 三态语义:拿到锁返 LockHandle、被持有返对方 LockRecord、
原语不可用/加锁被拒返降级句柄(响亮 WARNING,不阻断启动)。

- errno 分流:仅 EACCES/EAGAIN/EWOULDBLOCK 视为「被持有」;ENOLCK 等
  归原语不可用 —— 否则 NFS/SMB 上 flock 被拒会被误报为「已有实例在跑」。
- 回写元数据失败(OSError)不穿出;记录不可序列化时先 release 再降级,
  不留悬空 fd。
- 原语缺失横幅下沉到 _acquire_*_lock 的三个缺失点,避免同一事件两条诊断
  与 _PRIMITIVE_MISSING_WARNED 被污染造成的二阶静默。
- 测试 31 条,含 seek(0) 回归与父目录/旗标隔离判别用例。
Task 3 of docs/plans/2026-10-21-desktop-single-instance.md.

父进程 spawn 真子进程持锁:子进程打印 HELD <pid> 后保持 30s,父进程
以 10s 硬上界读到后必须被挡(返回子进程记录,pid/port 双匹配),
terminate 后必须能重取 —— 证锁随进程退出自愈,不依赖 pid 探活。

变异自证:_acquire_os_lock 直接 return True 时该用例转红。

清理:terminate 包 suppress(OSError) 防止顶层顶替断言失败;pump.join
超时留痕;healed 后加反向自证区分真锁与降级句柄。
Task 5 of docs/plans/2026-10-21-desktop-single-instance.md.

抢锁必须先于搬库,故 instance_lock 要在 start.py 里排在 bootstrap 之前导入。
它进入 _BOOTSTRAP_SAFE_IMPORTS 的唯一凭据是『不在模块级 import database』,
由新增的 test_instance_lock_module_does_not_import_database 钉住(两型变异自证)。

同时修 _mentions_database:原实现只查 node.module,漏掉 from delector import
database(模块名落在 alias 上)—— 那正是『判据看着在跑、实际恒真』的同型缺陷。
Task 4 of docs/plans/2026-10-21-desktop-single-instance.md.

claim_instance 是 main / 桌面壳 / 日后 supervisor 共用的唯一判据:
resolve_data_dir -> 抢锁 -> 拿锁后才探端口 -> 回写真实端口。

- 端口候选 8000 + 8002..8010;8001 是 Go supervisor 保留端口,显式指定也拒绝。
- 显式 --port 只试它、不顺延(已对外承诺的地址不能偷偷换);自动模式顺延并写
  一条含新旧端口的提示(stderr 不可用时退 logger,绝不静默)。
- 归因优先级 unknown > version_mismatch > data_dir_mismatch > same;
  pid==0 与 port<=0 都归 unknown(抢锁与回写端口之间的合法中间态)。
- 任一早退/异常路径都释放锁,否则同进程测试会互相污染成假红。
- Android 短路:不取锁、不探端口,返回降级句柄。
…lback

Task 6 of docs/plans/2026-10-21-desktop-single-instance.md.

main() 现在先 claim_instance 再决定:AlreadyRunning 按 reason 分派并早退
(不动库、不 import delector.server),Ready 用选定端口并 try/finally 放锁。

- 第二腿:锁降级时(无锁原语/目录不可建/文件打不开)在顺延**之前**对
  被占候选端口做身份探测,命中即判 same —— 否则会为同一个库起第二个服务。
  真锁在手一律不探测(锁是唯一判据)。
- same 分支打开页面前复核 /api/health,绝不为无响应端口开页面。
- 失败文案同一句只写一路(stdout 或 stderr/logger),不再重复两遍。
- LockHandle 新增 is_degraded 属性,start.py 不再跨模块读私有 _fd。

tests/test_start_lifecycle.py:原用例钉的 seam(is_port_in_use 早退)已被
本任务移除,改为真持锁注入,断言未放宽且新增退出码断言。
Task 6 复审的两条黄卡:
- _fail/_say 在 sys.stdout 为 None(窗口化)时必须落 logger,新增 2 条用例
  钉住(变异:把 else 分支改成 pass 即转红)。
- _handle_existing_port 的保留注释纠正为事实:当前无生产调用者。
Task 7 of docs/plans/2026-10-21-desktop-single-instance.md(Tier 3 / HITL,文案与退出码已获用户批准)。

_run_desktop_inner 首步换成 start.claim_instance(与 --server-only、脚本入口同一份
判据),端口由它选定,并在所有返回路径上放锁。

- --port 默认改为 None(自动:8000 起顺延到 8010;显式指定则不顺延)。
- 六种 AlreadyRunning 各有获批的人话文案,统一 exit 3;不含占位值(vunknown/进程 0)。
- 第二腿:锁降级时先探测被占端口的身份,命中即判「已在运行」——否则会为同一个
  库起第二个服务(这条接线由 main 级用例钉住,删掉即红)。
- 删 _check_port_or_fail 原来那套自探端口/身份的实现,改成 claim 的薄封装(保留函数名:
  阶段标记夹逼守卫与 window-lifecycle AST 守卫以它为锚)。

测试适配:port_guard 改写 6 条(same/version_mismatch/降级探测/自动顺延/显式不顺延/
放锁),desktop_shell 两条挂死用例改到新判定点(is_port_in_use 的桩再也拦不住启动),
阶段标记提取器改为穿透 try 容器(try 包裹后标记下沉一层,不穿透只数到 1 枚)。
Task 8 of docs/plans/2026-10-21-desktop-single-instance.md.

/api/wb/lan-info 此前把 port 写死 8000。端口顺延到 8002 之后,手机端会按错端口
配对、超时后提示失败,而主机日志里一切正常。

- build_server 把真实端口写入 app.state.server_port(getattr 保护非 FastAPI app)。
- 端点改读该值,缺省 8000(TestClient / Android 既有行为不变);刻意不用
  request.url.port —— 那个值来自 Host 头,可被伪造。
- 三条用例:读真实端口 / 缺省 8000 / build_server 确实写入(写入侧单独钉,
  否则端点用例靠手动 setattr 照样全绿)。
Task 9 of docs/plans/2026-10-21-desktop-single-instance.md.

- docs/agents/architecture.md:新增「桌面单实例与端口顺延」节(锁路径、8000/8001/8002–8010
  端口表、显式 --port 不顺延、锁与元数据分文件的原因、第二腿的适用条件、退出码)。
- docs/agents/ops.md:「单实例锁」排障入口(理由由永远看锁不看端口、不要手删 lock)。
- WORKMEMORY:PROJECT_OVERVIEW 当前状态 + 递延项移除;work.log 记 DECISION 事件。
Task 10 前置:计划要求合入前填写 Deviation Table。
…s=skip

CI 挂了:'Mypy type gate (desktop entry surface, strict)' 报 3 处 no-any-return
(start.py:610/653、desktop.py:883)。

根因不是类型真错,而是我本地复跑时漏了 CI 那条命令里的 --follow-imports=skip:
被跳过的模块符号退化成 Any,直接把 import 进来的调用/常量 return 出去就被判
'Returning Any from function declared to return ...'。本地不带该 flag ⇒ 全绿。

修法:三处都显式落到带注解的局部变量再 return(不引入 type: ignore)。
已用 CI 的**逐字命令**复验:mypy --platform win32 --strict --follow-imports=skip
desktop.py package_windows.py conftest.py start.py ⇒ 4 files, no issues。
第 13 条「三类假红」补第四类:门禁命令漏一个 flag ⇒ 本地假绿,只有 CI 红。

2026-10-22 实例:手打入口面那档 mypy 时漏了 --follow-imports=skip(--platform win32 与
--strict 都对),被跳过的模块退化成 Any ⇒ 三处 no-any-return 只在 CI 暴露。
纪律:复跑门禁 MUST 从 ci.yml 复制 run: 整行,不凭记忆重打。
单实例锁交付后的实测基线:2584 passed + 2 skipped / 580s、156 个 test 文件、
mypy 119/160/4 三档零错误、六道闸全绿、PR #172 CI 全绿(含 Windows Portable Smoke)。
LOC:后端+入口 40,001 / tests 61,312 / static/js 16,801。
@ROM4n2
ROM4n2 merged commit b8ae976 into master Oct 22, 2026
9 checks passed
@ROM4n2
ROM4n2 deleted the feat/desktop-single-instance branch October 22, 2026 11:20
@ROM4n2 ROM4n2 mentioned this pull request Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant