fix(mining): 修复 Windows 挖掘锁状态误报

锁诊断优先使用当前后端实际持锁对象确认自身归属,避免 Windows 排他锁阻止二次读取文件时误报未知持有者。真实外部占用仍按陈旧判定处理,并拒绝终止当前持锁后端。

验证:83 项相关 pytest 通过,Ruff 和 git diff --check 通过。原生 Windows 安装包仍待实机复验。

Signed-off-by: shy3130 <shy3130@qq.com>
This commit is contained in:
shy3130
2026-11-01 20:02:10 +08:00
parent ffe1ff0d3e
commit e60c438328
4 changed files with 58 additions and 5 deletions
+4 -1
View File
@@ -169,7 +169,10 @@ def get_lock_status(request: Request) -> dict[str, Any]:
"""挖掘锁诊断: 持有者线索 + 是否允许强制释放 (fail-closed 判定)。"""
from app.services.mining_lock_admin import lock_status
return lock_status(request.app.state.repo.store.data_dir)
return lock_status(
request.app.state.repo.store.data_dir,
lock=getattr(request.app.state, "mining_process_lock", None),
)
@router.post("/lock/force-release")
+11 -4
View File
@@ -49,23 +49,30 @@ class MiningLockAdminError(ValueError):
def lock_status(
data_dir: Path | str,
*,
lock: MiningProcessLock | None = None,
self_pid: int | None = None,
now: float | None = None,
stale_after: float = STALE_AFTER_SECONDS,
) -> dict[str, Any]:
"""汇总挖掘锁状态: 是否被持有、持有者线索、是否允许强制释放。"""
pid = os.getpid() if self_pid is None else self_pid
held = probe_lock_held(data_dir)
owns_lock = lock is not None and lock.acquired
held = owns_lock or probe_lock_held(data_dir)
holder = read_lock_holder(data_dir)
holder_source = "file" if holder else None
if held and holder is None:
if owns_lock and (holder is None or holder.get("pid") != pid):
# Windows denies reads of the locked byte through a second handle,
# even in this process. The live owned handle is authoritative.
holder = {"pid": pid, "role": "app"}
holder_source = None
if held and not owns_lock and holder is None:
# 旧格式/损坏内容 (典型: 旧版本残留后端进程): 从 OS 层面反查持有者,
# 让这类锁也能走人工确认的强制释放, 而不是让用户手动猜进程。
located_pid = locate_lock_holder_pid(data_dir)
if located_pid is not None:
holder = {"pid": located_pid, "role": "unknown"}
holder_source = "os-scan"
mine = held and bool(holder) and holder.get("pid") == pid
mine = owns_lock or (held and bool(holder) and holder.get("pid") == pid)
holder_pid = holder.get("pid") if holder else None
holder_alive = _pid_alive(holder_pid) if holder_pid else False
pid_consistent = (
@@ -109,7 +116,7 @@ def force_release(
stale_after: float = STALE_AFTER_SECONDS,
) -> dict[str, Any]:
"""终止陈旧持有者并让本进程接管锁; 任何不满足条件的情况直接拒绝。"""
status = lock_status(data_dir, stale_after=stale_after)
status = lock_status(data_dir, lock=lock, stale_after=stale_after)
if not status["force_releasable"]:
raise MiningLockAdminError(status["reason"] or "不可强制释放")
if lock.acquired:
+41
View File
@@ -301,6 +301,47 @@ def test_lock_endpoints_free_lock(tmp_path):
assert "未被持有" in resp.json()["detail"]
def test_lock_endpoint_recognizes_own_lock_when_metadata_read_is_denied(tmp_path, monkeypatch):
"""Windows forbids a second handle from reading the locked first byte."""
client = _api_client(tmp_path, acquired=True)
lock = client.app.state.mining_process_lock
lock.acquire()
original_read = Path.read_bytes
def read_bytes(path):
if path == lock.path:
raise PermissionError("locked byte is unreadable through another handle")
return original_read(path)
monkeypatch.setattr(Path, "read_bytes", read_bytes)
monkeypatch.setattr("app.services.mining_lock_admin.locate_lock_holder_pid", lambda _: None)
try:
body = client.get("/api/backtest/mining/lock").json()
assert body["held"] is True
assert body["mine"] is True
assert body["holder"]["pid"] == os.getpid()
assert body["force_releasable"] is False
assert "当前后端" in body["reason"]
refused = client.post("/api/backtest/mining/lock/force-release")
assert refused.status_code == 409
assert "当前后端" in refused.json()["detail"]
assert lock.acquired is True
finally:
lock.release()
def test_lock_endpoint_does_not_treat_startup_flag_as_current_ownership(tmp_path, monkeypatch):
client = _api_client(tmp_path, acquired=True)
monkeypatch.setattr("app.services.mining_lock_admin.probe_lock_held", lambda _: True)
monkeypatch.setattr("app.services.mining_lock_admin.read_lock_holder", lambda _: None)
monkeypatch.setattr("app.services.mining_lock_admin.locate_lock_holder_pid", lambda _: None)
body = client.get("/api/backtest/mining/lock").json()
assert body["held"] is True
assert body["mine"] is False
assert body["holder"] is None
assert body["force_releasable"] is False
@_POSIX
def test_lock_endpoints_full_recovery_loop(tmp_path):
proc = _spawn_holder(tmp_path)
+2
View File
@@ -110,6 +110,8 @@ run 级的“有效折”统计只描述因子赛道:它按每折被选中的
**挖掘锁冲突的降级与强制释放**:数据目录的 `.mining_process.lock`(flock)保证同一时刻只有一个应用进程负责挖掘;持锁者会把 `{pid, started_at, role}` 写进锁文件(flock 仍是唯一事实,内容只是诊断线索,旧格式 `"0"` 视为持有者未知)。撞锁不再导致后端启动失败——面板以降级模式运行:挖掘启动入口(手动/自动/周度调度)返回 409,守护复验等其他功能不受影响;挖掘页顶部显示持有者横幅(pid、角色、挖掘存储静默时长)。当且仅当满足陈旧判定(持有者信息可读、进程存活且 pid 未被复用、无进行中的挖掘运行、挖掘存储 10 分钟内无写入——全部 fail-closed,任一不满足即拒绝并给出原因)时,可一键「强制停止并接管」:终止持有者进程并由当前后端获取锁,挖掘入口立即恢复。不做启动时自动清理——终止进程是破坏性操作,必须人工确认。
锁状态接口优先使用当前后端实际持有的锁句柄确认归属。Windows 的字节范围排他锁会阻止另一个句柄读取锁文件,即使两个句柄属于同一进程;读取诊断信息失败时,不能把已持锁的当前后端误报成未知残留进程。真正的外部占用仍按上述陈旧判定处理,启动标志本身不能代替实际持锁状态。
每个运行目录包含 manifest、compact summary、最近 256 条事件和四个 Parquet artifact。大面板、完整矩阵对象和逐折宽结果不会通过 worker IPC 返回。
## 自动运行