车东 Che, Dong

Sun 18 October, 2026

20:24 fix(render): 给所有文档渲染子进程加上界(PHPMAN_CMD_TIMEOUT,默认 15s)(#248 #247) » Recent Commits to phpman:master
fix(render): 给所有文档渲染子进程加上界(PHPMAN_CMD_TIMEOUT,默认 15s)(#248 #247)

生产上 `/man/DCB-DCBX/markdown` 跑 **36.4 秒**、`/pydoc/local/markdown` 29.6 秒、
`/pydoc/FTP/markdown` 30.5 秒,而响应体分别只有 34 / 415 / 756 字节。在 mod_fcgid 的
固定进程池下,一次这样的请求就把一个槽占住半分钟 —— 几个并发就能让池饱和,其余所有人
一起吃 503。13 处 exec() 原来**一个超时都没有**。

- 新增 `src/exec.php`:`phpManExec()` / `phpManExecString()`,用 `timeout(1)` 包住命令;
  探测一次并缓存(只看 /usr/bin 与 /bin 的固定路径,不为它再 fork 一次 `command -v`),
  探测不到就退回裸 exec —— macOS 没有 timeout(1),本地开发不受影响
- `PHPMAN_CMD_TIMEOUT` 默认 **15s**,不是 10s:实测 `pydoc3 -k json` 本身就要 9.5 秒,
  10s 会把正常路径也切掉。mf 遥测显示 p50=799ms / p99=4.09s,上界切的是病态尾部
- 14 处调用点改用它(man 2 / pydoc 3 / ri 3 / perldoc 4 / info 2 + 1 shell_exec / apropos 1)

**实现时踩到一个真 bug,值得记下来**:`timeout N <命令>` 只约束**第一个简单命令**。
`timeout 1 sleep 30; echo never` 会被 shell 解析成 `(timeout 1 sleep 30); echo never`
—— sleep 被杀掉后 `echo never` 照跑。而 source_perldoc.php 的命令本身就是管道
(`perldoc -l … | head -1 | tr … | xargs …`),不包起来等于没上界。第一版在 macOS 上
测不出来(没有 timeout(1),全部走裸 exec 分支),拿到服务器上一跑 8 个断言全红。
修法:经 `/bin/sh -c` 包一层(这也正是 PHP 的 exec() 在 Unix 上用的形式)。

#247 的降级路径:`getPydocSearchPage()` 在 `pydoc3 -k` 超时或无结果时退到 FTS
(`pydocSearchLinesFromFts()` 复用 searchFtsBySource 的 json 形态再映射回行)。
这是**降级不是替代** —— 索引里的描述是常量 `'Python 3 module'`,替代会丢真描述;
但有名字好过挂 20 秒然后报错。

`test/unit/test_info_valid_files.php` 跟着修了一处:它原来只 require 一个 source 文件,
而 source 文件本来就依赖加载顺序(h()、formatManPerlDoc() 等),之前能过是偶然。

测试 `test/unit/test_exec_timeout.php`(18 断言)在**两端都跑**:macOS 上 13 passed
(切断类断言跳过并显式说明),服务器上 18 passed —— 切断、退出码 124、无 timeout(1)
时退回裸 exec、`PHPMAN_CMD_TIMEOUT=0` 不限制,都覆盖到了。全套 650 passed / 0 failed。

Co-Authored-By: Claude code 2.1.287 with deepseek-flash <noreply@taotoken.net>
04:14 fix(cache): 负缓存行一律复用 —— 空正文是「搜过了、没结果」,不是「没缓存」(#247) » Recent Commits to phpman:master
fix(cache): 负缓存行一律复用 —— 空正文是「搜过了、没结果」,不是「没缓存」(#247)

4d71cbd 的修法是错的,这条是更正。

那一版要求负缓存行**带非空正文**才复用(`trim($row['content']) !== ''`)。
但 #247 要治的恰恰是「名字根本不存在」这一类:`pydoc3 -k` 匹配不到任何
东西,兜底渲染出 0 字节。于是行永远不满足条件,扫描每次照跑。

v4.12.4 上线后实测(同一个不存在的名字连打三次):

  request 1:  http=404  time=14.19s
  request 2:  http=404  time=10.71s
  request 3:  http=404  time= 9.90s

遥测与缓存行指向同一件事:

  mf ... elapsed=12756ms peak=2.0MB bytes=0
  cache: status=not_found  content_len=0  hits=6

即:行一直在,正文一直是 0 字节。

更正:负缓存行**一律复用**,不看正文是否为空。行的语义是「已定论」——
正文是那次搜索的输出,没有匹配时就是空的;空是**答案**,不是**缺值**。
#247 之前写的旧行同理,不需要迁移。

用例把这条钉住:空正文的兜底结果,第二次请求不得再跑扫描 —— 这正是
生产里失败的那条路径。全套 637 passed / 0 failed。

Refs: #247
Co-Authored-By: Claude code 2.1.292 with deepseek-flash <noreply@taotoken.net>
03:22 fix(cache): 负缓存带上兜底正文,pydoc 未命中不再每次重跑 10s 扫描 (#247) » Recent Commits to phpman:master
fix(cache): 负缓存带上兜底正文,pydoc 未命中不再每次重跑 10s 扫描 (#247)

pydoc 未命中的兜底走 exec("pydoc3 -k …")(实测 9.99s 全量模块扫描),而负
缓存行写成 `set(..., null, 'not_found')` —— 正文没存。get() 又把 not_found
折成 '',调用处的 `trim($content) == ""` 于是每次都成立,每次请求都重跑一遍
扫描:连续两次请求实测 13.99s / 13.28s。22 小时里 pydoc 404 有 1,696 次,
约 4.7 CPU 小时。

改动分两半:

- src/cache.php 新增 PageCache::getWithStatus(),同时返回 status 与正文。
  行读取抽成私有 fetchRow(),get() 与 getWithStatus() 共用,仍是一次查询。
  get() 的契约不变 —— 仍把 not_found 折成 CACHE_SENTINEL_NOT_FOUND,
  现有调用方不受影响。
- 新增 cacheOrSearchFallback():命中带正文的负缓存行就直接复用(仍置
  isFallback,调用方照发 404);没有正文的旧行回退重算一次并补存正文 ——
  所以降级是「多跑一次扫描」,不是「渲染成空页」。

只改 pydoc 与 ri,因为只有这两个的兜底要 shell 出去(pydoc3 -k 9.99s、
ri ~3.9s)。man 的兜底是 apropos(实测 0.03s),perldoc/info 的 miss 分支
只写标记、不跑兜底 —— 都不需要动。

test/unit/test_negative_cache_body.php(25 条断言)钉住两半:第二次调用不得
再调用兜底生成器;无正文的旧负缓存行必须重算而不是渲染成空页。已注册进
test/run_all.php。

全套 634 passed / 0 failed。

未做(issue 里列的同族两条):用 FTS 取代 10s 扫描、给兜底子进程加超时。
本条落地后扫描降为每个 (name, format) 每 24h 一次,那两条的紧迫性下降。

Refs: #247
Co-Authored-By: Claude code 2.1.292 with deepseek-flash <noreply@taotoken.net>

Fri 16 October, 2026

03:49 refactor(cli): log-analyze.sh 参数化,项目里不再有站点专属配置 » Recent Commits to phpman:master
refactor(cli): log-analyze.sh 参数化,项目里不再有站点专属配置

这个脚本是 phpman 里唯一把 chedong.com 硬编码进去的地方,而且是它最复杂的一处。
现在项目只保留通用部分,站点相关的三项全部从外部进来:

- PHPMAN_WEBLOG_DIR(必填)—— 原来硬编码 `/home/chedong/logs/chedong.com/https`
- 位置参数 YYYY-MM-DD [YYYY-MM-DD] —— 不变
- PHPMAN_SPIDER_EXTRA(可选)—— 本站爬虫长尾,默认 $HOME/.phpman/spider-extra.txt

爬虫清单原本用两种形式各写了一遍(一个 grep 模式串 + 一个 300+ 字符的正则),
改一处必须记得改另一处。现在合并成一份 SPIDER_RE,两处 grep 共用;并且都用 -i,
所以 GPT/gpt、Claude/claude 这类大小写变体不用再各列一遍。项目自带的通用清单只剩
`bot|spider|crawler|slurp|ia_archiver|anthropic|meta-externalagent|oai-` —— 前三个
通用词覆盖绝大多数,后面几个是名字里不含它们的。

顺带删掉 `/blog/archives/`、`/blog/docs/` 两条 sed:那是 chedong.com 的 URL 模式,
与 phpman 无关。

修掉三个 bug(前两个是用户点名的,第三个是跑生产日志时暴露的):

1. 结尾两行打的是 `$LOG` —— 一个从未定义过的变量(脚本定义的是 `LOG_CURRENT`),
   所以「Log file / Last entry」从来没打出来过。现在按日志目录 + 文件数 + 最后一条
   记录来报,实测在生产上正常输出。
2. 归档收集循环用 `date -d "$D +1 day"` —— GNU 专有,macOS/BSD 上静默失败,循环
   一次都不跑,脚本看起来「没有日志」。改成 next_day() 同时试 GNU `-d` 与 BSD `-v`。
3. 提取 UA 时对所有引号段做匹配,于是请求行 `"GET /robots.txt HTTP/1.1"` 因为含
   "robot" 被当成一个爬虫报出来(生产上显示为 328 次)。现在排掉请求行。

另有一处实测踩到的坑:按日期**拼文件名**,不 glob 目录。DreamHost 上那个日志目录
不可列举(`ls` 报 Permission denied),glob 展开不出任何东西,而按名字直读可以。
glob 作为兜底保留,只在按名字一个都没找到时试一次。

还有一处:`wc -l < file` 在 BSD 上补前导空格,`$TOTAL` 拼进 awk 会变成语法错误
(原脚本同样存在)。统一 `tr -d ' '`;百分比也改成先算好再拼 —— 塞进 echo 的 $()
里需要嵌套三层引号,POSIX sh 下 awk 收到的是被截断的程序。

`make test` 补上 `cli/*.sh` 的 `sh -n`(原来只查 `deploy/*.sh`)。

实测:本地 fixture 走 BSD 分支正常;生产 2026-10-15 单日 62,103 行、爬虫 55.8%,
范围 10-15→10-16 合并 71,102 行。

Co-Authored-By: Claude code 2.1.287 with deepseek-flash <noreply@taotoken.net>
03:08 fix(release): RELEASE_ID 恢复净化 —— 它会被远端 shell 重新解析 (#250) » Recent Commits to phpman:master
fix(release): RELEASE_ID 恢复净化 —— 它会被远端 shell 重新解析 (#250)

#250 的独立安全审查没有发现达到报告门槛的漏洞(该改动集里没有不可信输入路径:
流进 shell / 路径 / 公网页面的值要么是 CLI/env,要么先过了
`^[0-9]+\.[0-9]+\.[0-9]+$` 校验),但指出一处**纵深防御回归**,是我引入的:

旧 RELEASE_ID 有净化,注释里写着理由「sanitized because the tag ... must stay a safe
path component」。新实现直接 `$$(cat .release-version)`,未净化。而 RELEASE_ID 在
_release-code 里是**不加引号地插在双引号 ssh 命令串内**的,会被远端 shell 重新解析 ——
一旦 .release-version 里出现 shell 元字符,就是生产主机上以部署用户身份的命令注入,
atomic-release.sh 那个单引号参数也会被顶破。

今天不可利用:.release-version 的唯一写入者是 next-version.sh,两条输出路径都校验成
vN.N.N。但那让这个安全属性**依赖两个文件之外的一个脚本** —— 哪天有人放宽校验(比如接受
VERSION=4.13-rc1,仓库里历史上就有 v3.6.1-readme 这种带后缀的 tag),它就立刻变成活的。
在这里净化,属性就回到本地。

实测:`v4.13-rc1'; id; echo '` → `v4.13-rc1___id__echo__`。

Co-Authored-By: Claude code 2.1.287 with deepseek-flash <noreply@taotoken.net>
02:52 docs: 发布流程文档同步到新的版本号方案,并归档实施计划 (#250) » Recent Commits to phpman:master
docs: 发布流程文档同步到新的版本号方案,并归档实施计划 (#250)

- README:发布流程改为「本地提交(不 push)→ make staging → make release」,
  新增一节说明 make release 的四步顺序(守卫 → 版本 → 部署 → 打 tag/push);
  第 5 步不再手工 git tag(tag 已由 release 创建)
- docs/05-PLAN.md 的 Update Flow 重写:补上守卫与版本来源,说明 _release-tag
  为什么必须在所有步骤之后(release-reindex 的 index 重建跑在 _release-code 之后)
- CHANGELOG:新增 Fixed 段
- docs/superpowers/plans/:实施计划归档,附一节「实施中的偏差」记录三处计划没预见到
  的问题(precheck 输出 tag 快照、守卫加有界重试、RELEASE_STAMP 在 parse time 解析)

Co-Authored-By: Claude code 2.1.287 with deepseek-flash <noreply@taotoken.net>
02:31 feat(release): 版本号以 origin 的 tag 为准,打 tag 并入 make release (#250) » Recent Commits to phpman:master
feat(release): 版本号以 origin 的 tag 为准,打 tag 并入 make release (#250)

删除 make tag / tag-minor —— 它们从本地 tag 列表推下一个版本号,在 tag 陈旧的机器上
会算出**倒退**的版本号并 push 到 origin,永久留下一个错 tag。这是 #250 的写入版:
make release 只是把错的版本显示出去,make tag 是把它写下来。

make release / release-reindex 现在:
- 先跑 deploy/release-precheck.sh(三条守卫),任何 rsync 之前
- 版本号由 origin 的 tag 快照算出(默认补丁 +1,VERSION=4.13 显式覆盖)
- 部署全部成功后由 _release-tag 打 annotated tag 并 push master + tag
- 加 test-unit 前置:守卫的回归此前可以在完全没被测过的情况下发出去

实现中发现并修掉一个自己引入的洞:_release-code 原本让 next-version.sh 自己
ls-remote 一次。到 github.com:22 的连接在这台机器上偶发超时(实测 10 次挂 1-2 次),
那次失败会得到空输入,被当成「远端还没有 tag」算出 0.0.1 —— 一个静默的版本倒退,
正是这套机制要消灭的东西。改成由 precheck 把它已经取到、并据以判断过的**同一份**
tag 快照交给下游(precheck 成功时把快照打到 stdout),一次网络请求,且守卫看到的
状态与算版本号用的状态必然一致。

同时给守卫的网络调用加了有界重试(默认 3 次 / 2s 线性退避):守卫的职责是判断状态,
一次网络抖动只能说明「暂时问不到」,不重试会有 10-20% 的假阳性让 make release 没法用。

RELEASE_STAMP 在 parse time 解析一次:recipe 每行是独立 shell,行内 date 会让同一
次部署的各行拿到不同时间戳,把一次部署劈成两个 release 目录。

实测:守卫 3 在脏树上拦住(报出具体文件);make -n test/logcheck/verify 均无网络调用。
609 passed / 0 failed。

Co-Authored-By: Claude code 2.1.287 with deepseek-flash <noreply@taotoken.net>
02:05 feat(staging): 版本串改为 staging-<sha>[-dirty],不再冒充发布版本 (#250) » Recent Commits to phpman:master
feat(staging): 版本串改为 staging-<sha>[-dirty],不再冒充发布版本 (#250)

tag 现在只在 release 那一刻打,所以 staging 部署的通常是**尚未打 tag** 的提交。
继续用 `git describe --abbrev=0` 会让它报上一个 release 的版本号 —— 跑的却是更新的
代码,方向正好反了(issue 里那个 staging 停在 v4.12.2、生产反而是 v4.11.2-59 的倒挂
就是这么来的)。sha 是诚实的;工作树脏时补 -dirty,因为那个 sha 描述不了实际传上去
的字节。

- 新增 DEPLOY_PATHS:会被实际上传的路径(src/ phpMan.php cli/ deploy/
  phpman.config.php.example phpman.css phpman.js),既是「脏」的判定范围,也是
  release-precheck.sh 的入参,只在一处定义
- STAGING_VERSION 用 git status --porcelain -uall -- $(DEPLOY_PATHS),与守卫 3
  同一套判定:路径外的草稿笔记不标记,src/ 里没 git add 的新文件会标记
- 两条 $(shell) 都是纯本地命令(无网络),放 parse-time 可以接受

实测:干净树 → staging-13f0ee8;改动 src/config.php → staging-13f0ee8-dirty;
根目录放 scratch-note.txt → 仍是 staging-13f0ee8。

Co-Authored-By: Claude code 2.1.287 with deepseek-flash <noreply@taotoken.net>
02:02 feat(deploy): release-precheck.sh — 发布前的三条守卫 (#250) » Recent Commits to phpman:master
feat(deploy): release-precheck.sh — 发布前的三条守卫 (#250)

把版本号的权威来源改成远端之后,版本号从**远端**算、代码却从**本地**工作树传,
两者一旦不同源,新算出的版本号就会被贴到别的代码上。三条守卫各堵一种:

1. origin/master 是 HEAD 的祖先 —— 本地落后时,会算出新版本号、部署旧代码、
   再把旧 HEAD 打成新版本号推走。先 git fetch 再判断,否则本地跟踪引用本身可能
   就是陈旧的,检查形同虚设;fetch 失败(断网/认证)必须中止,不能当成「没落后」
2. HEAD 未被 origin 上的 tag 指着 —— 同一个提交不发布两次。只查远端不查本地,
   这样「部署成功但 push 失败」能自愈(_release-tag 在 push 失败时会删掉本地 tag)
3. 部署路径上工作树干净 —— 部署传的是工作树里的 src/ 与 phpMan.php,脏树会部署出
   tag 指向的提交之外的字节

实现上踩到两个坑,都在注释里留了记录:

- 剥 "refs/tags/" 前缀不能用 `sed 's#.*refs/tags/##'`:`.*` 贪婪,会把行首的 sha
  一起吃光,awk 就再也匹配不上 HEAD(annotated 与 lightweight tag 都漏检)
- 脏检查必须加 `-uall`:默认 git 把整个未跟踪目录折叠成一行 "?? src/",报错只说得出
  目录名、说不出是哪个文件,而这条报错的全部价值就是让人知道该处理什么

18 条断言,每条都造真实临时仓库(含 detached HEAD 与 origin 不可达的负例)。
全套 607 passed / 0 failed。

Co-Authored-By: Claude code 2.1.287 with deepseek-flash <noreply@taotoken.net>
01:58 feat(deploy): next-version.sh — 版本号以远端 tag 为权威来源 (#250) » Recent Commits to phpman:master
feat(deploy): next-version.sh — 版本号以远端 tag 为权威来源 (#250)

`git describe` 读的是运行 make 那台机器碰巧有的 tag,同一个提交在两台机器上会被
描述成不同的东西 —— 生产就是这样把已经越过 v4.12.2 的代码标成了
v4.11.2-59-gf06411a。新脚本从 stdin 收 tag 列表(调用方喂
`git ls-remote --tags origin`,测试喂 fixture),自身不碰网络。

- 只认三段纯数字:仓库里真实存在的两段 tag(v2.0…v4.5)、带后缀的
  (v3.6.1-readme)、非版本的(phpMan_2/time)与 annotated tag 的 `^{}` 行都滤掉
- 显式 VERSION 归一化成三段:`VERSION=4.13` → 4.13.0。不归一化的话下一次默认发布
  扫不到 v4.13,会从 v4.12.2 算出 v4.12.3 —— 正是要消灭的静默倒退,而且守卫拦不住
- 显式 VERSION 必须严格大于远端最新,等于也拒(否则同一版本号会被发两次)
- 远端一个三段 tag 都没有时给 0.0.1 并在 stderr 说明(首次发布就是这个状态)

顺带新增 `make test-unit`:`make test` 只跑语法检查,从不调用 test/run_all.php,
所以这些守卫的回归此前可以在完全没被测过的情况下发出去。它是 release 的前置
(下一条提交接上)。

36 条新断言;全套 589 passed / 0 failed。

Co-Authored-By: Claude code 2.1.287 with deepseek-flash <noreply@taotoken.net>
01:39 docs: 发布版本号完整性设计(#250) » Recent Commits to phpman:master

Tue 13 October, 2026

09:48 feat(logs): 机器格式渲染埋点,区分「客户端放弃」与「进程被杀」 (#244) » Recent Commits to phpman:master
02:47 fix(deploy): 切完 current 重启 PHP worker,原子发布不再被 opcache 吞掉 » Recent Commits to phpman:master
02:18 docs: 其余 9 个文件里的 v5.0 提法改为 v4.12.0 » Recent Commits to phpman:master
02:14 docs(plan): 05-PLAN 里的 v5.0.0/v5.0 改为 4.12.0 » Recent Commits to phpman:master
02:09 docs(CHANGELOG): 定版 4.12.0 » Recent Commits to phpman:master
02:00 fix(make): 远端 $HOME 的校验不再打死所有目标 » Recent Commits to phpman:master
01:53 feat(logcheck): 轮转归档不再让报告看起来「一直很干净」 » Recent Commits to phpman:master
01:33 feat(logs): make log-rotate —— 轮转 phpMan 错误日志 » Recent Commits to phpman:master

Mon 12 October, 2026

03:12 docs(deploy): atomic-release.sh 记录切换后的 realpath 缓存窗口 (#243) » Recent Commits to phpman:master