车东 Che, Dong

Sat 03 October, 2026

20:05 feat(cache): renderer_version 让渲染器变更自动失效缓存 » Recent Commits to phpman:master
feat(cache): renderer_version 让渲染器变更自动失效缓存

The page cache stores *finished* output under a 210-day TTL
(PHPMAN_CACHE_TTL_FOUND), so a renderer fix never reached a page that had
already been cached — the row kept serving the old render until its TTL ran
out. That is not theoretical: the OSC-8 and JSON-markdown fixes shipped in
this release were invisible on production until 1,703 rows were found and
deleted by hand. Nothing in the codebase could have found them, and nothing
would have caught the next one either.

RENDERER_VERSION (a hand-bumped integer in src/config.php) is written on
every PageCache::set() and is part of get()'s WHERE clause, so a row from a
different renderer reads as a miss and is re-rendered on its next request,
overwriting itself in place through the existing UPSERT. Nothing has to be
purged and there is no re-render herd — unlike a CACHE_SCHEMA_VERSION bump,
which deletes every row and re-renders the lot at once.

Filtering in get() is the whole point, not an optimisation: schema v5→v6
dropped the previous attempt at this, cache.generator_version, precisely
because nothing ever read it back, so it invalidated nothing. A version is
only worth storing if get() filters on it. For the same reason
RENDERER_VERSION is deliberately NOT derived from a deploy stamp or source
hash — that would invalidate on every deploy, including ones that cannot
change a single byte of output.

Schema 7 → 8 adds cache.renderer_version to both the central table and the
shards (which have no meta table, so they carry the version in
PRAGMA user_version). Existing rows default to 0, which no current
RENDERER_VERSION equals, so the backlog drains gradually through real
traffic. cli/cache.php stats gains a per-shard `stale` count — the only
visible sign a bump took effect.

While adding the v7→v8 step, the "unknown future schema" guard turned out to
be latent: it read `if ((int)$row >= 7)`, which was only correct while the
version *was* 7, because the surrounding `!==` guard excludes equality and so
made `>= 7` reachable only above 7. Bumping to 8 made it reachable for a
database at exactly 7, which would have run the v7→v8 migration and then
deleted every row it had just preserved. Now `> (int)CACHE_SCHEMA_VERSION`.
Demonstrated both ways: with the literal the central probe reports rows=0
instead of 500, and the new test goes 33 passed / 2 failed → 35 / 0.

Verified on real data rather than fixtures: the shard migration was run
against a copy of a production `ri` shard (14,337 rows) — user_version 7→8,
all rows at 0, a pre-existing page reads as a miss, the row is NOT deleted,
and re-rendering restores it in place with no duplicate. Full suite 454
passed, 0 failed.

docs/ISSUE-FIX-PLAN-2026-10.md is folded into docs/05-PLAN.md as the v4.11.3
section and removed.

Co-Authored-By: Claude code 2.1.285 with deepseek-flash <noreply@taotoken.net>
11:42 docs(CHANGELOG): 更正 #229 的运维前提——make reindex 不重建 cache_fts » Recent Commits to phpman:master
docs(CHANGELOG): 更正 #229 的运维前提——make reindex 不重建 cache_fts

cli/build-index.php 只重建 search_fts,全代码库没有别的地方碰 cache_fts,
所以 make reindex 修不了这次错位的索引(这也正是它一直没被发现的原因:
应用层没有查询读 cache_fts,只有单元测试在用)。改成给出正确的逐分片重建
命令。

Co-Authored-By: Claude code 2.1.285 with deepseek-flash <noreply@taotoken.net>
11:15 docs(CHANGELOG): 记录 #227 #228 #229 #230 #231 #232 #233 #234 #235 #236… » Recent Commits to phpman:master
docs(CHANGELOG): 记录 #227 #228 #229 #230 #231 #232 #233 #234 #235 #236 #237 #221

本轮修复的十二个 issue 全部补上 Unreleased 条目:Added 记 cli/cache.php,
Changed 记 baseUrl() 取配置 scheme 与两处文档同步,Fixed 记其余九项
(markdown 字节上限、cache_fts rowid、info 目录发现、MCP 空页不抓 TLDR、
JSON 不混入 markdown 链接、install.sh 的 mbstring 预检、deploy 脚本的
GNU mv 预检、staging-reindex 的 sitemap 拆分)。

#229 那条注明了运维前提:生产分片的 FTS 索引当前是错位的,部署后必须
make reindex 重建。

Co-Authored-By: Claude code 2.1.285 with deepseek-flash <noreply@taotoken.net>
10:59 docs: 修正 emoji/mcp 文档漂移,并让 v4.4 测试说明符合现实 (#237 #221) » Recent Commits to phpman:master
docs: 修正 emoji/mcp 文档漂移,并让 v4.4 测试说明符合现实 (#237 #221)

#237 —— 文档说的是已经不存在的东西:

- `mcp` 从「URL 可选格式」里去掉(CLAUDE.md / AGENTS.md)。它是
  `handleMcp()` 调用的内部渲染核心,`PHPMAN_OUTPUT_FORMATS` 只有
  html/markdown/json。AGENTS.md 与 CLAUDE.md 逐字节同源,一起改。
- emoji 缓存行改成「inert」:v4.10(`7740029`)删了写方,v5.0
  (`5bf0025`)连读方也删了(默认视图回退、`/markdown` 偏好、
  `CACHE_FORMAT_EMOJI_*` 常量、永不过期规则)。唯一残留是 v3→v4
  迁移的 preserve list,故意留着:改写已发布的迁移会改变一个旧库
  今天迁移过来时会删掉什么。涉及 CLAUDE.md / AGENTS.md /
  docs/00-INDEX.md / docs/01-PRODUCT.md §2.12 / docs/03-CACHE.md
  §2.2 §10.2 / docs/05-PLAN.md:147。
- docs/07-STRATEGY.md 的历史复盘不动(描述当时的状态)。

#221 —— v4.4 那段「Tests unchanged」写反了:它声称每个测试文件都把
`require 'phpMan.php'` 换成 `require PHPMAN_HOME . '/src/bootstrap.php'`,
但测试从来没这么改过,而且照做会 fatal —— `PHPMAN_HOME` 只由
`phpMan.php:29` 定义,`src/config.php:152` 却无保护地用裸常量;PHP 8
下未定义常量是 `Error`。改成仓库根目录还会把 `PHPMAN_CACHE_DIR`
指到 `<repo>/db`。改成陈述事实:测试保持 `require 'phpMan.php'` +
`PHPMAN_TEST_MODE`,共 18 个文件(17 个注册在 run_all.php)。

纯文档改动,零代码;测试 443 passed, 0 failed。

Co-Authored-By: Claude code 2.1.285 with deepseek-flash <noreply@taotoken.net>
10:46 fix: staging sitemap 拆分、install.sh 预检 mbstring、deploy 脚本要求 GNU mv (#2… » Recent Commits to phpman:master
fix: staging sitemap 拆分、install.sh 预检 mbstring、deploy 脚本要求 GNU mv (#231 #230 #232)

**#231 — `staging-reindex` 漏了 sitemap 拆分。** `1fdd975`(#225)改了
`release-reindex`/`reindex`/`reindex-staging`,漏了 `staging-reindex`:它还在生成
未压缩、只有 html、没有 --sitemap-url / AI sitemap / llms.txt 的
`sitemap.phpman.xml`。也就是说 staging 从没演练过生产真正在提供的东西。
改为与 `reindex-staging` 逐字节一致的两条命令形式。

(检查过 staging docroot:`sitemap.phpman.xml` 并不存在,没有孤儿文件要删。)

**#230 — install.sh 缺 mbstring 预检。** 全仓库非 vendor 的 mb_ 调用有 5 处未加
守卫:format_html.php:194、search_index.php:14,23、cache.php:657、
source_search.php:9(issue 漏了最后一处)。失败点比 issue 说的更早:install.sh
自己会跑 `cli/build-index.php`,走到 search_index.php:14 就 fatal——全新安装是在
安装过程中死的,不是首次渲染 man 页时。加 `check_mbstring()`(仿 check_sqlite3,
按平台给 apt/dnf/yum/pacman/brew 提示),在 run_checks 里 check_sqlite3 之后调用;
check_php 的提示和 README 的 Requirements 也补上。不做优雅降级:format_html.php:194
在做实事(清掉 overstrike 处理后残留的非法 UTF-8),跳过会改变输出甚至产出非法
UTF-8,4 处调用点各写 fallback 不值得——保持硬依赖。

**#232 — deploy 脚本依赖 GNU `mv -T`。** `atomic-release.sh:60,:84` 用了 `mv -T`。
原子性来自 rename(2) 而不是 -T;-T 的作用是让 mv 把 `current` 当**名字**。没有它,
GNU mv 会解引用符号链接,把 `.current.tmp` 移进 `releases/<old-id>/`——切换静默
失效,还在旧 release 里留个杂链接。这些脚本只经 `ssh ... sh ...` 跑在 Linux 服务器上,
所以保留 -T、加显式守卫,让它以清晰信息失败而不是 "illegal option"。
(本机实测:macOS BSD mv 上守卫正确失败,服务器 GNU coreutils 9.4 上通过。)

验证:
- 服务器 `sh -n deploy/*.sh` + `bash -n install.sh` 通过
- 本机 `make test` 全通过;`make -n staging-reindex` 展开为拆分后的两条命令
- mbstring 探针:本机(无 mbstring)exit 1,服务器 exit 0
- 全量测试 443 passed, 0 failed

Co-Authored-By: Claude code 2.1.285 with deepseek-flash <noreply@taotoken.net>
10:39 fix: baseUrl() scheme 取配置、空页不抓 TLDR、JSON 不混入 markdown 链接 (#233 #235 #… » Recent Commits to phpman:master
fix: baseUrl() scheme 取配置、空页不抓 TLDR、JSON 不混入 markdown 链接 (#233 #235 #236)

三个独立的小修,因为都落在 src/format_json.php 及其测试上,放在一个提交里。

**#233 — 反代下 canonical 用错 scheme。** issue 指错了函数:getSafeHost()
(src/util.php:61-75)只解析 HOST 和 PORT,压根不读 scheme,而它返回的是
host[:port],调用方(src/format_common.php:136)自己拼 scheme——往里塞 scheme
会破坏契约。scheme 属于 baseUrl()。改为:配置了 PHPMAN_BASE_URL 就以它的
scheme 为准,没配置才用请求推断(HTTPS / X-Forwarded-Proto),与 host 的既有
原则对称。线上 chedong.com 不在 TLS 终止反代后面,所以这不是线上故障修复,
是正确性/可移植性。

**#235 — MCP 未知命令触发外部抓取。** buildJsonData() 无条件调
fetchOfficialTldr(),缓存未命中时打最多 4 个外部请求(tldr-pages ×3 + cheat.sh,
各 timeout=5)。MCP 的 cli_help 未命中路径走的就是空页 + buildJsonData。
实测一个没见过的命令 1.64s 并写一行 tldr_cache;最坏 ~20s。

- buildJsonData():空 sections 时跳过 TLDR 抓取。
- format_mcp.php:180,264:改为读 $data["tldr"],不再各自重抓一遍
  (原写法绕过了上面的守卫,抓取只会转移不会消失)。
- 顺带修同一处的滥用面:tldr.php 的读过滤对 not_found 行也用
  PHPMAN_CACHE_TTL_FOUND(210 天)。负缓存应该用 1 天的
  PHPMAN_CACHE_TTL_NOT_FOUND,和 PageCache 的处理一致。否则每个**不同的**
  未知命令名都要付一次慢抓取并留一行,持钥者可枚举未知命令持续外联并撑大
  tldr_cache。改为按 source 分支的 CASE。实测:2 天前的 not_found 行已过期,
  同期的 found 行仍有效。

**#236 — JSON sections[].content 混入 markdown 链接。** OSC 8 超链接
(groff 为 .UR/.UE 生成)在 cleanTerminalOutput() 里被渲染成 `[text](uri)`,
markdown 和 JSON 共用这一处替换。而 buildJsonData() 本来就剥掉了 **/_,
所以 OSC 8 链接是唯一漏进 JSON 字符串的 markdown 构造。
加 $linkStyle 参数(默认 'markdown'),JSON 传 'plain' 只留文本。

验证(服务器 ~/.phpman_testsuite):
- 全量测试 443 passed, 0 failed(新增 12 条断言)
- 未知命令走 buildJsonData:0.00s、无 tldr 键、tldr_cache 行数不变
- 直接调 fetchOfficialTldr 同一命令作对照:1.64s、写一行
- TTL 分支:2 天前的 not_found 过期、found 保留

Co-Authored-By: Claude code 2.1.285 with deepseek-flash <noreply@taotoken.net>
10:20 fix(info): getValidInfoFiles() 不再硬编码 /usr/share/info (#234) » Recent Commits to phpman:master
fix(info): getValidInfoFiles() 不再硬编码 /usr/share/info (#234)

`getValidInfoFiles()` 只 glob `/usr/share/info/*.info*`。info 装在别处的主机
(MacPorts /opt/local/share/info、Homebrew /usr/local/share/info、或设了 INFOPATH)
就返回空集。这不是"降级":getInfoIndex() 的 json/mcp 分支会直接丢掉解析不到的条目,
空集让整份 /info 索引变成 `"items": []`, `"count": 0`。

改动:

- 目录改为发现式:INFOPATH(冒号分隔,最权威,因为 info 二进制读的就是它)
  ∪ GNU 编译内置默认(/usr/local/share/info, /usr/share/info)
  ∪ `dirname(info --where dir)`(覆盖前缀不在前两者里的重定位安装)。
  `info --where dir` 报的是文件(…/dir,没有 dir 时是第一个 .info),所以取
  dirname(),并用 is_file() 校验——某些构建会往输出里加装饰。
- 安全网:`$filtering = !empty($validFiles)`,5 处 `!isset($validFiles[...])`
  改成 `$filtering && !isset(...)`。发现失败时退回加链接的旧行为,而不是清空索引。

实测(服务器 info 7.1):`info --where dir` → /usr/share/info/coreutils.info.gz;
本机 macOS → /opt/local/share/info/dir。两种情况 dirname() 都对。

测试:新增 test/unit/test_info_valid_files.php(9 断言,子进程风格,因为函数读
环境变量)。断言 INFOPATH 生效、冒号分隔、伪造路径不产生条目、以及
`info --where dir` 报告的目录被完整收录。不硬编码任何系统路径——/usr/share/info
在 MacPorts 机器上不存在,反之亦然。

已验证该测试对旧代码失败:旧代码带不带 INFOPATH 输出完全一致(从不读 /tmp/oldinfo
里的 foo/bar)。

Co-Authored-By: Claude code 2.1.285 with deepseek-flash <noreply@taotoken.net>
10:09 fix(cache): 缓存分片工具链,flush/stats 不再漏掉分片 (#228) » Recent Commits to phpman:master
fix(cache): 缓存分片工具链,flush/stats 不再漏掉分片 (#228)

v4.11 把页面缓存按 mode 分片成 phpman_cache_<mode>.db,但工具链还盯着中心库:

- `make cache-flush` 跑的是 `rm -f ~/.phpman/db/phpman_cache.db*`。这个 glob
  只匹配中心库和它的 -wal/-shm —— 分片名在第 13 个字符就分岔了(`_` vs `.`),
  所以一个分片都删不掉,命令却报告成功。
- `make cache-stats` 只 `ls` 中心库,六个分片一个都看不见。

改动:

- 新增 `cli/cache.php stats|flush`。flush 删掉各分片文件,然后只对中心库执行
  `DELETE FROM cache` —— 不删文件,因为 FTS 搜索索引和 TLDR 缓存跟它同库,
  旧命令的 `rm -f` 会把它们一起扔掉。
- `pageCacheDb()` 加 `$create` 参数:stats/clear 这类只读调用者不该为了数行数
  而把没写过的分片物化出来(`new SQLite3($path)` 会建文件,$isNew 分支还会建表)。
- `stats()`/`clear()` 改为问 `pageCacheDb()` 而不是看文件系统:它对手里已经
  握着连接的进程会返回活连接 —— 句柄可以比文件活得久,只看路径会漏。
- `test_page_cache.php` 的 `cleanupTmpDir()` 补上单例重置:删文件不会关连接,
  不重置的话下一段测试继续往已删除的 inode 里写。

验证(服务器 ~/.phpman_testsuite,PHP 8.2.30):
- stats 在空安装上不创建任何文件,六个分片全报 (absent)
- 写入后 stats 报出 man=1 / info=1
- flush 删掉 2 个分片文件,中心库和其中的 tldr 行(command='ls')存活,
  中心库遗留 cache 表清空
- 全量测试 419 passed, 0 failed

Co-Authored-By: Claude code 2.1.285 with deepseek-flash <noreply@taotoken.net>
09:47 fix(format): markdown 正文加上整页字节上限,避免超大页耗尽内存 » Recent Commits to phpman:master
fix(format): markdown 正文加上整页字节上限,避免超大页耗尽内存

markdown 没有 payload 上限(json/mcp 有 1MB/512KB)。正文全量拼接后还要
gzcompress 进缓存,于是超大页把无界字符串一路翻倍到 memory_limit 耗尽——
机制上就是一封 500,而且错误日志里不留 PHP fatal(OOM 被杀不会写)。

实测(web SAPI memory_limit=128M,已用探针确认):
  合成 52.7MB 页 → "Allowed memory size of 134217728 bytes exhausted"
                   in format_markdown.php
  线上最大页 info py → 17.9MB 原文 / 18.0MB 输出 / 60MB 峰值 / 8.0s,正常

上限按实测取 24MB,高于所有真实页面(今天没有任何页面会被截断),
同时挡住失控增长;截断时附提示并指向 json 视图。

mdAppendLine() 拒绝放不下的整行而不是超发后再停:保留正文严格落在预算内,
半行本来也没有意义。

HTML 未动——它拼进 <pre>,按行截断会破坏 AGENTS.md 要求的 XHTML 纯净性
(未闭合的 <b>/<u>/<a>),需要按标签边界单独做。

Fixes #227

Co-Authored-By: Claude code 2.1.285 with deepseek-flash <noreply@taotoken.net>
09:46 fix(cache): 用真实 id 同步 cache_fts,不再依赖 last_insert_rowid() » Recent Commits to phpman:master
fix(cache): 用真实 id 同步 cache_fts,不再依赖 last_insert_rowid()

UPSERT 的 ON CONFLICT DO UPDATE 分支不更新 last_insert_rowid():新连接上返回 0,
长连接上返回上一次 INSERT 的陈旧 id。syncFts() 把该值当作 cache_fts 的 rowid,
而 cache_fts 是 external-content 表(content_rowid='id'),于是索引静默错位:
rowid 为 0 时任何 MATCH 直接抛 "fts5: missing row 0 from content table",
陈旧 id 则把两行数据对错。Web SAPI 是 CGI,每请求新连接,命中的是前者。

改为显式 SELECT id。不用 RETURNING:install.sh 的安装底线是 PHP 7.2+,
随附 SQLite 可能早于 3.35(Ubuntu 20.04 是 3.31)。

既有覆盖写用例抓不到这个 bug——它覆盖前紧挨着的 INSERT 就是同一行,
陈旧 id 恰好等于正确 id,断言碰巧通过。新用例在两次写之间插入一行不同的记录,
修复前报 expected 25 / actual 26。

生产分片的 FTS 索引目前是错位的,部署后需重建(make reindex)。

Fixes #229

Co-Authored-By: Claude code 2.1.285 with deepseek-flash <noreply@taotoken.net>
04:33 feat(deploy): link $PHP_HOME/phpMan.php at the current release too » Recent Commits to phpman:master
02:22 fix: stop a foreign Host header from choosing our canonical URL » Recent Commits to phpman:master
00:21 feat(deploy): atomic releases — flip phpMan.php + src/ with one rename » Recent Commits to phpman:master

Fri 02 October, 2026

09:33 refactor: 删掉 /status 端点,清掉 v5.0 之后残留的 emoji 读取侧死代码 » Recent Commits to phpman:master
02:23 perf(format): cleanTerminalOutput 改为按引用原地清理,markdown 峰值降 16MB » Recent Commits to phpman:master

Thu 01 October, 2026

22:57 fix: cache_fts 的 title 列补回 cache 表,虚拟表恢复可查询 » Recent Commits to phpman:master
20:02 fix: 错段号与退役的 /mcp 后缀 301;移除 cache.generator_version;补 cmd-input label » Recent Commits to phpman:master
18:31 fix: OSC 8 链接限制 scheme 白名单,并修 OSC 剥离的顺序问题 » Recent Commits to phpman:master
18:20 fix: man 页的 OSC 8 超链接转成真链接,不再泄漏成 ]8;; 垃圾 » Recent Commits to phpman:master
17:29 fix: man 页的 ANSI 颜色码不再泄漏成 [34m 和坏链接 » Recent Commits to phpman:master