# 修复记录：更新按钮"失效"的连锁排查（2026-10-13）

用户报告：「数据已更新，但桌面双击更新的按钮好像失效了」。

排查下来是**四个独立问题叠在一起**，其中两个是我自己之前埋的坑。

---

## 问题 1：漏网的旧看门狗（我埋的坑）

发现一个 **`cscript.exe` 进程（pid 11916）从 2026-10-08 23:51 一直运行了 5 天**，
跑的是 `watchdog.vbs`——那是我 10-08 做「用 cscript 捕获错误输出」测试时启动的，
**测试完忘了杀掉**。

它加载的是 **10-08 的旧代码**（健康探测阈值 2 次、并且会往 `public_url.txt`
写占位文字 `tunnel was dead; restarting...`）。

### 为什么我 10-12 没发现它

我 10-12 检查看门狗实例时用的是：

```powershell
Get-CimInstance Win32_Process -Filter "Name='wscript.exe'" | Where-Object { $_.CommandLine -like '*watchdog*' }
```

**只过滤了 `wscript.exe`，`cscript.exe` 从来没进过列表。** 所以我一直以为只有 1 个看门狗，
实际上**一直有两个并存**。10-13 17:07:51 那次占位文字就是它写的。

**教训**：检查脚本宿主必须同时覆盖 `wscript.exe` **和** `cscript.exe`。

---

## 问题 2：`daily_price.parquet` 被截断（核心故障）

```
_duckdb.InvalidInputException: No magic bytes found at end of file
  'C:\FundamentalWorkbench\data\processed\daily_price.parquet'
```

- 文件末 4 字节是 `83 41 80 0B`，而正常 parquet 必须是 `50 41 52 31`（`PAR1`）
- 修改时间 **17:07:33**，正是 16:53:34 那次运行**在写这个文件时被中断**的时刻

**为什么重跑还是失败（关键）**：`price.py` 的缓存逻辑是

```python
if target.exists() and target.stat().st_mtime >= newest_source and ... and not force:
    return PriceBuildResult(False, ...)   # 跳过重建
```

而**损坏文件的 mtime 比源文件新**（17:07:33 > 16:34:44），
所以每次重跑都**跳过重建**，损坏文件永远留着 → 永远失败。

### 处置

调用 `build_daily_price(settings, force=True)` 强制重建：

```
重建完成: rebuilt=True rows=6149942 stocks=5224 用时 212s
```

重建后首尾字节均为 `PAR1`，DuckDB 可正常读取（614 万行、5224 只、最新交易日 2026-10-13）。

---

## 问题 3：残留锁阻塞后续所有运行

16:53:34 那次运行被中断后，**锁文件没有被清理**：

```
锁: pid=9484 started=2026-10-13T16:53:34     ← 用 Get-Process 和 WMI 都查不到该进程
```

而 `_acquire_lock()` 的旧实现**只用「文件年龄」判断死活**：

```python
age_seconds = time.time() - LOCK_PATH.stat().st_mtime
if age_seconds <= 6 * 60 * 60:
    raise RuntimeError("已有日更任务在运行")   # 进程早死了，却要等 6 小时
```

于是**按钮点下去只会得到「已有日更任务在运行」**——看起来就是"失效了"。

### 处置：改成按进程存活判断

新增 `_lock_owner_pid()` 与 `_pid_alive()`（Windows 用 `OpenProcess` +
`GetExitCodeProcess` 判断 `STILL_ACTIVE`）。锁的持有进程若已不存在，**立即接管**。

实测（用那个真实的残留锁）：

```
锁内容 owner pid : 9484
该 pid 存活吗     : False
接管残留锁        : 成功 ✅   (旧逻辑会抛「已有日更任务在运行」)
```

---

## 问题 4：看门狗读不了带 BOM 的地址文件（会误杀隧道）

看门狗用 `CleanUrl(ReadAll(public_url.txt))` 取地址。实测：

```
ReadAll 长度      : 58
旧逻辑 CleanUrl   : []          ← 返回空！
新逻辑 ReadUrlText: [https://penalties-aging-accent-avatar.trycloudflare.com]
```

带 UTF-8 BOM 的文件经 FileSystemObject 读取后，**字符串里不含 ASCII 的 `http`**，
`InStr` 找不到 → 地址解析为空 → 看门狗认为地址无效 → **5 次后杀掉健康隧道**，
形成「杀隧道 → 重建 → 再杀」的循环。

日志已经证实它在计数了：

```
2026/10/13 17:27:13  public probe failed (1/5) url=
2026/10/13 17:28:14  public probe failed (2/5) url=
```

### 处置

新增 `ReadUrlText()`：优先用 `ADODB.Stream` + `charset=utf-8`（能正确剥离 BOM），
失败则回退到 `AsciiOnly()` 逐字节扫描只保留可打印 ASCII。
`CleanUrl()` 仍保留作最后一道工序。

修复后观察 210 秒：**看门狗日志无新失败记录**，地址未变、隧道未被误杀。

---

## 顺带修掉的两处

### 1. 看门狗静默无日志

它以前**从不写日志**，所以我完全无法判断它有没有在工作。
新增 `deploy\logs\watchdog.log`，记录每次动作（探测失败计数、杀进程、起隧道）。

### 2. `taskkill` 静默失败 → 改用 WMI Terminate

看门狗用 `shell.Run "taskkill /F /PID ..."` 杀僵死隧道，**实测没生效**
（cloudflared 存活了 4.7 天没被杀掉），而且被 `On Error Resume Next` 静默吞掉。
改为 WMI 的方法调用：

```vbscript
rc = cfProcs.Item(k).Terminate()
Note "killed pid=" & targetPid & " rc=" & rc
```

### 3. `_last_error_line` 抓到 `^`

DuckDB 的多行错误以一行孤立的 `^`（指向出错 token）结尾，
我的提取逻辑取"最后一行"就拿到了 `^`，于是状态文件显示
`M1 数据刷新 失败，退出码 1：^；旧看板仍可用`。
改为**优先匹配含 `Error`/`Exception` 且带 `:` 的行**，否则取第一行含字母数字的行。

---

## 问题 5：快速隧道又被注销（第 2 次）

```
ERR Register tunnel error: "Unauthorized: Tunnel not found"
```

cloudflared 进程从 10-08 23:43 存活 4.7 天，但隧道在服务端已被注销。
上次是 24 天，这次约 5 天——**快速隧道的寿命不可预测**。

---

## 最终状态（2026-10-13 17:35）

```
看板 asOf   : 2026-10-13
runId       : dd143654dc5249b6bc090bcf9b4042b6
update state: success / 更新完成 3/3
公网地址    : https://penalties-aging-accent-avatar.trycloudflare.com
公网探测    : HTTP 200
看门狗      : 1 实例（新版，带日志 + 进程存活判锁）
健康检查    : VERDICT OK
daily_price : 首尾均为 PAR1，614 万行 / 5224 只 / 最新 2026-10-13
```

## 本轮修复清单

| 文件 | 改动 |
| --- | --- |
| `run_daily_update.py` | 锁改为按进程存活判断；`_last_error_line` 优先取异常行 |
| `deploy\watchdog.vbs` | 新增 `ReadUrlText`/`AsciiOnly`；WMI Terminate 替代 taskkill；新增 `watchdog.log` 日志 |

## 仍未解决 / 需要注意

1. **快速隧道寿命不可预测**（24 天、5 天都出现过），注销后地址必变。
   看门狗能自动重建，但你需要重新查询地址。
   **根治只能换 Tailscale 或 Cloudflare 命名隧道。**
2. **`public_url.txt` 的 BOM 来源仍未查清**。
   `start_tunnel.ps1` 已确认使用无 BOM 写入（实测 `WriteAllText(UTF8Encoding(false))` 首字节 `68 74 74`），
   但文件确实出现过 BOM。现已让**读取端容错**，所以即使再出现也不影响运行。
3. **中断的运行会留下损坏的中间产物**。这次是 `daily_price.parquet` 被截断。
   建议：**不要中途关闭更新窗口**。若已中断，检查 `data\processed` 下的 parquet
   首尾字节是否为 `PAR1`。
