车东 Che, Dong

Tue 13 October, 2026

09:48 feat(logs): 机器格式渲染埋点,区分「客户端放弃」与「进程被杀」 (#244) » Recent Commits to phpman:master
feat(logs): 机器格式渲染埋点,区分「客户端放弃」与「进程被杀」 (#244)

/markdown、/json、/mcp 的渲染耗时达到 PHPMAN_MF_LOG_MS(默认 500ms)时,
向 phpman_error.log 写一行:

  mf mode=man name=zshall sec=1 fmt=markdown elapsed=4164ms peak=94.2MB bytes=1666546

为什么需要它:机器格式上的 5xx 现在什么都不留,于是「客户端等不及断开」和
「mod_fcgid 杀掉了慢进程」在日志里完全同形——而两者的修法相反(前者要缩短
渲染或改成分块/预渲染,后者要调 FastCGI 超时或降内存)。这一行让二者**靠有无
区分**:5xx 且能找到对应行 = 渲染跑完了、投递失败;5xx 且没有对应行 = 进程在
到达终点前就没了。

elapsed 从 REQUEST_TIME_FLOAT 起算,因此是 PHP 自己的墙钟时间,不含 mod_fcgid
排队等待;拿它和 access log 里客户端观测到的耗时对比,正是暴露那段排队的办法。
peak 取 memory_get_peak_usage(true)——web SAPI 的 memory_limit 是 128M,而
info py 这类页面实测峰值 60MB,被杀与不杀就在这条线附近。

阈值就是全部的流量控制,且落在两个分布天然分开的位置:2026-10-13 在 staging
实测,缓存命中 30-90ms,同一页冷渲染 400-4200ms(zshall 4.2s、pydoc/numpy
2.5s、perldiag 1.7s)。所以 500 让日志量与「被首次抓取的页数」成正比,而不是
与每天 28k 次机器格式请求成正比。PHPMAN_MF_LOG_MS=0 关闭。

埋点放在格式分支之前、body 输出之前,一次调用覆盖三种格式,同时保证「5xx 且
无此行」确实意味着进程死于渲染中途,而不是渲染完成后在投递阶段失败。

页面名直接从 URL 来,所以 phpManLogSafeField() 先折叠控制字符再截断:否则一个
名字里带换行的请求就能往这份日志里追加一行伪造的 `PHP Fatal error`——而被伪造
的正是读取这份遥测的那个文件。

刻意**不**动 RENDERER_VERSION:渲染输出一个字节都没变,bump 只会白白作废整个
页面缓存。

新增 test/unit/test_mf_render_log.php(24 条断言):阈值边界(400ms 静默 /
600ms 记录 / 恰好 500ms 记录 / 0 与负数关闭)、各字段齐全、elapsed 从请求起点
起算、换行/CR/TAB/ESC 注入无效、超长名字截断、以及包装函数写到被重定向的
error_log 的行为。

Co-Authored-By: Claude code 2.1.287 with deepseek-flash <noreply@taotoken.net>
02:47 fix(deploy): 切完 current 重启 PHP worker,原子发布不再被 opcache 吞掉 » Recent Commits to phpman:master
fix(deploy): 切完 current 重启 PHP worker,原子发布不再被 opcache 吞掉

opcache.revalidate_path=0 下,编译产物按「请求路径」而不是解析后的
真实路径缓存,所以 retarget current 对已运行的 worker 完全不可见,
而且不会过期:编译来源仍存在、mtime 未变,validate_timestamps 也就
一直认为旧字节码是新的。

2026-10-13 生产就是这样:current 已经指向 releases/v4.12.1-…,站点
仍连续 20 多分钟返回 v4.11.2-50-g2870e58,池子里既有切换前也有切换
后启动的 worker;一次 opcache_reset() 立刻切过去。atomic-release.sh
第 4 步现在做等价的事(worker 退出、mod_fcgid 按需拉起),make
rollback 因为复用同一脚本也一并覆盖;PHPMAN_PHP_RESTART=0 可跳过。

同时修正脚本里那段错误解释:它把原因归到 realpath_cache_ttl 并建议
等 300 秒——revalidate_path=0 时 opcache 根本不会再解析路径,TTL
不会参与。
02:18 docs: 其余 9 个文件里的 v5.0 提法改为 v4.12.0 » Recent Commits to phpman:master
docs: 其余 9 个文件里的 v5.0 提法改为 v4.12.0

版本定为 4.12.0 后,除 05-PLAN 之外还有 19 处写着 v5.0(AGENTS/CLAUDE 各 3、CHANGELOG 2、
docs/ 下 11)。逐条核对后替换,其中一处不是改名:

CHANGELOG 的 `/status` 条目原写「v5.0 removed the batch-enhance subsystem」,但
`cli/batch-enhance.php` 是 v4.10.0(`7740029`,2026-08-08)删的——用
`git log --diff-filter=D` 与 merge-base 核对过,7740029 在 v4.10.0 已在,
而 5bf0025 首次发布就是 v4.12.0。那句于是改为 v4.10.0,否则会把「写入侧何时移除」
说成这次发布的事。

其余 18 处都是同一件事的标签(自 4.12.0 起 deploy 原子、emoji 读取侧移除、
CACHE_SCHEMA_VERSION 与两条迁移梯子移除)。docs/02-UI-DESIGN.md 那处是页脚文案的
示意,改成 v4.12.0 后与实际渲染一致(staging 页脚已确认显示 v4.12.0)。
02:14 docs(plan): 05-PLAN 里的 v5.0.0/v5.0 改为 4.12.0 » Recent Commits to phpman:master
docs(plan): 05-PLAN 里的 v5.0.0/v5.0 改为 4.12.0

版本定为 4.12.0 之后,plan 里 9 处旧提法就过期了:两处「自 v5.0 起 emoji 读取侧已
移除」(`5bf0025`)、三处「atomic since v5.0」、Phase F 的 `git tag v5.0.0`、
`git revert v5.0.0..HEAD` 配方、W4 路线图行与总计行。

其中 1184 行不只是改号:原文是「(major bump: removed features)」,而 4.11 → 4.12
是 minor bump(移除项在 4.x 里照发),所以括注一并改成符合事实的说法。

刻意避开两处 `max_cost_per_day_usd: 5.00`(1005/1037 行)——那是配置值不是版本号。

顺带记录一个原因:`v4.2` 是仓库里真实存在的远古 tag,把 `git revert v5.0.0..HEAD`
写成 `v4.2..HEAD` 会指向完全不同的历史,所以「4.2」按 4.12 处理。
02:09 docs(CHANGELOG): 定版 4.12.0 » Recent Commits to phpman:master
docs(CHANGELOG): 定版 4.12.0

把 Unreleased 的内容定为 4.12.0(2026-10-13),并在上方留一个空的 Unreleased 供下
一轮使用。仓库里 4.x 各版本向来只加日期标题、不补链接定义(底层那段 [Unreleased]
还指着 compare/v3.5...HEAD),这里沿用既有做法。

同时删掉两个 tag:

- v9.9.10 —— 9-27 打在旧提交 0c7231f 上的越轨 tag,只在本地(远端没有)。它在时
  `make tag` 按 tag 名排序会把自动递增算成 v9.9.11。
- v5.0.0 —— 我上一轮误按 docs/05-PLAN.md 里那句 "git tag v5.0.0" 打的,本地与远端
  一并撤销。

删完后 git describe 回到 v4.11.2-54-g9d419a9。
02:00 fix(make): 远端 $HOME 的校验不再打死所有目标 » Recent Commits to phpman:master
fix(make): 远端 $HOME 的校验不再打死所有目标

STAGING_HOME / DEMO_HOME 在 parse 时解析,紧跟两个 $(error):任一 host 的 SSH 抖
一下(DNS、连接数、网络)整个 Makefile 就解析不了——包括与那个 host 无关的目标。
实测 `make logcheck TEST_HOST=127.0.0.1`(模拟 staging 不可达)在改动前直接:

    Makefile:76: *** Failed to resolve STAGING_HOME — SSH to 127.0.0.1:22 failed.

而 logcheck 只跟生产对话;同一次抖动也让它和 make log-rotate 一起失败过。

改为 require-staging / require-production 两个前提目标,只挂在真正用到那些路径的
目标上(部署、重建索引、缓存、回滚)。logcheck、log-rotate、verify 只用 host:port,
刻意不挂——它们必须在另一个 host 不可达时照常工作。错误文案沿用原文以便既有文档
不漂移。

解析仍是每 run 一次 `:=`:改成递归 `=` 会让 recipe 里每次引用都再跑一遍 ssh。
01:53 feat(logcheck): 轮转归档不再让报告看起来「一直很干净」 » Recent Commits to phpman:master
feat(logcheck): 轮转归档不再让报告看起来「一直很干净」

`make log-rotate` 之后当前日志从 0 行开始,而 logcheck 只看它,于是报告会读成
「这个站从来没出过错」——与「刚轮转过」的真实含义正好相反。两处都补上:

- phpMan 错误日志段增加一行归档摘要(份数、占据空间、最新一份的时间跨度),
  按名字倒序取最新——与 cli/log-rotate.sh 的 prune 同一顺序(名字里零填充的
  %Y%m%d-%H%M%S 就是时间序,且不受 mtime 相同影响)。
- `newest fatal anywhere` 改为 `newest fatal in this log`:它本来只 grep 当前文件,
  「anywhere」在轮转后就是过度承诺;无归档时保持原文案,有归档时补一句指向上面
  的历史。

验证:夹具三种情形(无归档 → 纯 (none);有归档 → 带提示;多份归档 → 取名字最新
的那份)均符合预期;对生产实跑,输出为
  rotated: 1 file(s), 20M beside it; newest spans 09-Jun-2026 04:35:10 → 09-Oct-2026 22:00:46
  newest fatal in this log: (none; earlier history is in the rotated file(s) above)
01:33 feat(logs): make log-rotate —— 轮转 phpMan 错误日志 » Recent Commits to phpman:master
feat(logs): make log-rotate —— 轮转 phpMan 错误日志

生产上 ~/.phpman/logs/phpman_error.log 已 20MB / 74k 行,旁边还堆着约 50MB 的
v4.10 LLM 工具链遗留日志(batch-enhance.log 45MB、batch_*/cache_*/enhance_*、
两个 .pid)。日志只增不减,没有任何轮转机制。

- cli/log-rotate.sh:把当前日志重命名为 `<log>.<%Y%m%d-%H%M%S>`,保留最新 N 份
  (LOG_ROTATE_KEEP,默认 5),并报告目录里其它文件(**只报告、不删**——那些不是
  我们的东西)。空日志原样保留,这样每日 cron 不会攒出一堆空轮转文件。
- Makefile:`make log-rotate` 走 stdin 管道到服务器执行,不必先部署;
  `LOG_ROTATE_DRY_RUN=1 make log-rotate` 可预览。
- AGENTS.md / CLAUDE.md:命令表补一行。

运行期安全性:轮转是一次 rename(2),而 error_log() 每次写入都会重新打开路径,
所以下一条日志自然落到新文件,不需要重启任何服务。重命名后只有在文件仍不存在时
才创建空文件,避免截断掉这中间写入的请求。

实测中抓到一个自己写的 bug:prune 原本用 `ls -t` 取最新 N 份,但同一秒内产生的
多份轮转 mtime 相同,`ls -t` 退化成名字升序,把**刚生成的那份**排到最后删掉了,
反而留下更老的。日志名自带零填充时间戳,改为按名字倒序即确定且正确。夹具测试
据此加了「最新轮转必须仍在」的断言(只断言数量的那版没抓到)。

验证:夹具测试覆盖轮转/保留上限/空日志不轮转/dry-run/外来文件不被删/最新轮转保留;
对生产 `LOG_ROTATE_DRY_RUN=1` 实跑,输出与预期一致(20.5MB 待轮转、0 份旧轮转、
26 个其它文件 70MB 未触碰)。测试 528 passed / 0 failed。

Mon 12 October, 2026

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

切换是原子的,请求不会看到新旧混搭;但 PHP 每个进程只把入口脚本的符号链接解析
一次并缓存,realpath_cache_ttl(本机 300s)内常驻的 php-cgi 仍在执行
releases/<旧 id>/phpMan.php。旧对旧是自洽的(入口用 __DIR__ . '/src' 解析自己的
src),所以危害只在验证:2026-10-10 那次部署因此看起来「没生效」约 25 分钟——
新行为缺失,且一个全新 URL 的页脚显示上一版的 GIT_DESCRIBE,而那个值是准确的
(页面确实由上一版代码渲染)。

注释里写明排查姿势:先 readlink current 确认切换,再探测行为差异;若仍是旧行为
就等过 realpath_cache_ttl 重试,别据此判定部署失败或动手改代码。同时注明 opcache
不是原因(validate_timestamps=On 且 revalidate_freq=2)。

Sat 10 October, 2026

21:43 docs(search): 更正 FTS5 JOIN 注释与 CHANGELOG 里的放大机制 » Recent Commits to phpman:master
docs(search): 更正 FTS5 JOIN 注释与 CHANGELOG 里的放大机制

上一提交(9d7904f)把 LEFT JOIN 的放大说成「同名条目挂在两个 source 下
就匹配两次」,这是错的:source 与 section 在构造上是一一对应的(man 用
数字节、pydoc 用 'pydoc'、ri 用 'ri',见 search_index.php:552/641/687),
ON 里的 `m.section = s.section` 因此把 UNIQUE(name, section, source) 那条
路径完全挡掉了,没有哪个名字能在同一个 section 下同时属于两个 source。

真正能放大的是前缀分支:search_fts.name 存的是 expandNameForFts() 的输出
("git-commit git commit"),于是一条记录会匹配同 section 下所有是它空格
前缀的已索引名——"git" 和 "git-commit" 都算。

同时把「未观测到差异」写进 CHANGELOG:新旧 worker 全部收敛后,perl(301)、
ssh、tar、git-commit、autoconf 在线上都是「链接数 == 去重后链接数」;
staging 仍在 v4.11.2-46(改动前),它与生产结果的差异只出现在两台主机索引
本身不同的地方,从未出现重复条目。所以这次改的是「可证明无用且只可能产生
重复」的工作,不是在修一个已经看到的缺陷。

仅注释与文档改动,无行为变化。测试:529 passed, 0 failed。

Co-Authored-By: Claude code 2.1.286 with deepseek-flash <noreply@taotoken.net>
20:49 docs(plan): 记下第二轮分支清理(origin 上 4 个,其中 2 条先移植再删) » Recent Commits to phpman:master
20:45 fix(search): 去掉 FTS5 三处无用 LEFT JOIN,重复行不再吃满 LIMIT 预算 » Recent Commits to phpman:master
19:55 docs(plan): 记录两个陈旧分支的清理结论(核实吸收后才删) » Recent Commits to phpman:master
10:48 fix(search): FTS 清除失败不再被吞掉,并加 fts/meta 计数不变量 (#242) » Recent Commits to phpman:master
04:46 fix: 三处注释/门槛与实现不符,顺带统一 PHP 版本下限为 8.1 » Recent Commits to phpman:master
01:47 docs: 按最近两个月的变更整体校正文档与 README » Recent Commits to phpman:master

Fri 09 October, 2026

21:39 fix(router): 不使用 section 的模式下,section 段按垃圾段收敛 (#241) » Recent Commits to phpman:master
21:39 fix(config): tools_config.php 的兜底改为「未定义就定义」,部署改为原子替换 (#240) » Recent Commits to phpman:master

Thu 08 October, 2026

18:43 fix(cache): bump RENDERER_VERSION 到 2 —— 页脚改动没能到达已缓存页面 (#238) » Recent Commits to phpman:master
18:37 fix(search): 重建把清除并入事务,并拒绝提交空索引 (#239) » Recent Commits to phpman:master