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>
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 不会参与。
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)。
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 处理。
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。
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。
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)
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。
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)。
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>