Files
drouter/_dev/t-encoding-runtime.py
T
liuzhuohua ed5eb4428b 1.0.9 静态资源传输层优化 + 页脚版本号 + logo 回概述
## 性能:瓶颈在传输,不在代码

先量后改。helper 冷启动 0.44s(编译 22173 行)看着像瓶颈,但
drouter-helpd 是常驻守护,请求走 Unix socket,正常路径根本不 fork;
实测各 API 已是毫秒级。app.js 语法编译 18ms、顶层执行 18ms
(393 个顶层函数 / 13401 行)—— JS 执行不是瓶颈。

真瓶颈是 app.js 690,482 字节每次重传。**决定不拆包**:JS 执行只 18ms,
拆包引入首屏 waterfall 的风险大于收益。改走三条:

1. 缓存策略按「有没有指纹」区分(不是按扩展名)
   - 带 ?v=<mtime> → public, max-age=31536000, immutable
   - 没指纹 → 仍 no-cache(否则部署后用户拿到旧文件)
   - index.html → 永远 no-store(每次都要重新注入)
2. 编码协商 br > gzip > 原文,**逐级降级**
3. 压缩结果按 (path, mtime, size, enc) 缓存

真机实测:首次 690,482 → 200,137 字节,第二次打开 0 字节(304)。

## 逐级降级不是可选项

目标机**没装 python3-brotli**(只有 C 库 libbrotli1)。最初写的
_brotli_cached 在模块缺失时 `return data, None`,而 None 是 encoding,
调用方理解为「压不动,原样输出」—— 于是 583KB 裸传,**比优化前更慢**。
改成 (None, None) 明确表示「这条路不通」,由 _encode_body 继续试 gzip。

同时修:Accept-Encoding 探测必须认 q=0(br;q=0 是「明确不要」,
子串匹配会正好发给解不开的客户端 → 满屏乱码);编码必须进缓存键
(共用键会互相覆盖);brotli 固定 q5(q11 只省 10KB 却要 1.5-3s)。

## 宽 except 把 bug 装成了特性

兜底只接 ImportError 这类**真·预期**的缺失,不接 TypeError/ValueError
(「str 传给要 bytes 的接口」也是 TypeError,接了等于没接)。
早先的 `except Exception` 吞掉了一个 NameError(漏注入 _comp_lock),
表现是压缩功能完好但从不生效,页面照常能开、只看响应头的判据完全发现不了。

## 版本号:建立单一真源 packaging/VERSION

原来版本号在 4 个构建脚本各写死一个默认值 + openapi 里硬编码。
漏改一处就产出「文件名 1.0.9、control 里 1.0.8」的包 —— apt 按
control 认版本,dpkg -l 永远显示旧版且升不上去。

index.html 的 ETag 并入版本号:它发出去的是注入后的内容,
ETag 却按原文件算,只改 VERSION 文件时 ETag 不变,客户端换到 304
继续用旧副本,页面版本号永远停在旧版。

## logo 回概述

用 <a> 而不是 <div onclick>:Tab 能聚焦、Enter 能触发、读屏会念「链接」。
已在 dash 页时不重复 go()(go 会 stopPageTimers + 重拉数据)。
CSS 显式去 text-decoration/color(UA 样式表不声明仍生效),
:focus-visible 焦点环保留(纯键盘用户唯一的路标)。

## 顺带修掉一个已发布版本的真 bug

_vpn_env 里 os.path.join('/lib/modules', ...) 被留成了 '/nonexistent'
(反向验证脚本被 SIGTERM 打断,finally 没执行,注入留在源码里),
于是 mod_file 永远为空、**need_module 整个状态从四态里消失** ——
这正是 v1.0.8 release notes 声称修好的那个 bug(PVE 的 KVM 虚机内核
明明有 wireguard.ko.xz 却报 unsupported,前端配一句写死的
「通常说明跑在精简容器里」把排查方向带偏)。

新增 _dev/t-worktree-clean.py 扫注入哨兵,放在 sync.sh 所有判据之前 ——
基线被污染时,后面每一条红都不可信。

## 判据

新增 t-static-perf.py(42 项静态)、t-encoding-runtime.py(26 项**真跑**,
覆盖 9 种 Accept-Encoding 组合、brotli 缺失降级、两种编码不互相覆盖、
压不动直出、内部错误不被吞)、t-worktree-clean.py。
三个都做了反向验证,注入后确实变红。

反向验证本身也踩了坑:判据里 len(body) 在 body 为 None 时先崩 ——
退出码非 0 但没有 FAIL 行,看着像「跑了」,实际没人知道它验出了什么。
取长度一律过 _sz() 包一层。

全量回归 54 个判据 0 失败;真机验收 22 项全通过;
前端三查 47 视图 0 异常、26 项移动端适配全过。
2026-10-04 10:27:02 +08:00

248 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""**实际执行** drouter-web.py 的压缩协商函数,验证行为而不是文本。
t-static-perf.py 全是源码文本断言,能挡住「代码被改回去」,但挡不住
「代码还在、行为是错的」。这一份把 _accepts / _compress_cached /
_encode_body 抽出来真跑一遍,覆盖这些真实场景:
- br 可用 + 客户端要 br → br
- br 可用 + 客户端只要 gzip → gzip(**不能**给 br)
- **br 模块缺失 + 客户端只要 br → 必须退到 gzip**(真机就是这个情况)
- 客户端 br;q=0 → 不能给 br
- 都不支持 → 原文
- 同一文件先 gzip 后 br,两次结果都要正确(编码进键,不能互相覆盖)
- 压不动的小响应 → 原文
⚠️ brotli 用**注入的假模块**而不是真装一个:真机上 python3-brotli 是
可选依赖,装了就测不出降级路径了。假模块要能真的编出合法 brotli 流,
否则解压那步会假失败。
"""
import ast
import io
import os
import sys
import types
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
fails = 0
def chk(label, cond, extra=''):
global fails
if not cond:
fails += 1
print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra))
WEB_PATH = os.path.join(ROOT, 'backend/drouter-web.py')
WEB = io.open(WEB_PATH, encoding='utf-8').read()
TREE = ast.parse(WEB)
# ---- 抽函数(ast 精确取 def 节点,不用正则切)----
WANT = ('_accepts', '_want_gzip', '_want_brotli', '_compress_cached',
'_brotli_cached', '_gzip_cached', '_encode_body')
nodes = []
for n in TREE.body:
if isinstance(n, ast.FunctionDef) and n.name in WANT:
nodes.append(n)
got = set(n.name for n in nodes)
chk('六个压缩函数都能定位到', got == set(WANT),
'缺: %s' % (set(WANT) - got))
# 常量(GZIP_MIN / BROTLI_QUALITY / _COMP_MAX*)
consts = {}
for n in TREE.body:
if isinstance(n, ast.Assign) and len(n.targets) == 1:
t = n.targets[0]
if isinstance(t, ast.Name) and t.id in (
'GZIP_MIN', 'BROTLI_QUALITY', '_COMP_MAX', '_COMP_MAX_BYTES'):
consts[t.id] = n.value
# 缓存表是 AnnAssign/Assign 都可能
if isinstance(n, ast.Assign) and len(n.targets) == 1:
t = n.targets[0]
if isinstance(t, ast.Name) and t.id in ('_comp_cache', '_comp_bytes'):
consts[t.id] = n.value
chk('压缩相关常量都在模块顶层', len(consts) >= 4, '拿到 %s' % sorted(consts))
mod = ast.Module(body=[], type_ignores=[])
# 压缩链的必需依赖:**必须原样注入,不能用替身**。
# 教训:曾经漏注 _comp_lock,函数里一句 NameError 被 `except Exception`
# 吞成「返回原文」—— 压缩功能「完好」但从不生效,583KB 一直裸传,
# 而只看响应头的判据完全发现不了。
ns = {
'os': os, 're': __import__('re'), 'gzip': __import__('gzip'),
'threading': __import__('threading'),
}
for n in TREE.body:
if isinstance(n, ast.FunctionDef) and n.name in WANT:
mod.body.append(n)
elif isinstance(n, ast.Assign) and len(n.targets) == 1 and \
isinstance(n.targets[0], ast.Name) and n.targets[0].id in consts:
mod.body.append(n)
# 锁不是白名单里的常量,但它是压缩链的硬依赖
ns['_comp_lock'] = ns['threading'].Lock()
exec(compile(mod, WEB_PATH, 'exec'), ns)
_accepts = ns['_accepts']
_encode_body = ns['_encode_body']
_comp_cache = ns['_comp_cache']
def hdr(v):
return {'Accept-Encoding': v} if v is not None else {}
# ---- 1. _accepts:q 值语义 ----
for ae, want_gz, want_br, note in (
('gzip, deflate, br', True, True, '标准 Chrome 头'),
('gzip', True, False, '只认 gzip'),
('br', False, True, '只认 br'),
('br;q=0', False, False, '明确不要 br'),
('br;q=0.0', False, False, 'q=0.0 也不要'),
('br;q=0.5', False, True, 'q>0 就要'),
('gzip;q=0, br', False, True, '不要 gzip 但要 br'),
('', False, False, '空头'),
('identity', False, False, '只接受不压缩'),
):
g = _accepts(hdr(ae), 'gzip')
b = _accepts(hdr(ae), 'br')
chk('Accept-Encoding %-20r → gzip=%-5s br=%-5s(%s)'
% (ae, g, b, note), g == want_gz and b == want_br,
'期望 gzip=%s br=%s' % (want_gz, want_br))
chk('缺 Accept-Encoding 头时两个都判 False',
not _accepts(hdr(None), 'gzip') and not _accepts(hdr(None), 'br'))
chk('头是 None 时不抛异常', not _accepts({'Accept-Encoding': None}, 'br'))
# ---- 2. 无 brotli 模块 → 必须退到 gzip(真机就是这样)----
BIG = (b'drouter static asset payload. ' * 200) # > GZIP_MIN
def run(data, ae, with_brotli):
"""在指定 brotli 可用性下跑一次 _encode_body。"""
saved = sys.modules.get('brotli')
if with_brotli:
m = types.ModuleType('brotli')
# 用真 brotli 未必装了;假实现只要稳定可逆,就能验证「选了哪条路」
m.compress = lambda d, quality=None: __import__('gzip').compress(d, 9)
sys.modules['brotli'] = m
else:
sys.modules.pop('brotli', None)
_comp_cache.clear()
ns['_comp_bytes'][0] = 0
try:
return _encode_body(hdr(ae), None, data)
finally:
if saved is None:
sys.modules.pop('brotli', None)
else:
sys.modules['brotli'] = saved
def _sz(x):
"""安全取长度。被测代码返回 None 时**不能**让判据自己先崩 ——
判据崩溃 = 退出码非 0 但没有 FAIL 行,看起来像「跑了」,
实际上没人知道它验出了什么。反向验证时这条尤其致命:
bug 明明抓到了,输出却是一片 OK。
"""
try:
return len(x)
except TypeError:
return -1
body, enc = run(BIG, 'gzip, deflate, br', False)
chk('**没有 brotli 模块时要退到 gzip,不是吐原文**',
enc == 'gzip' and _sz(body) < len(BIG),
'enc=%s %d→%s 字节' % (enc, len(BIG), _sz(body)))
body, enc = run(BIG, 'gzip, deflate, br', True)
chk('有 brotli 且客户端要 br → 用 br', enc == 'br', 'enc=%s' % enc)
body, enc = run(BIG, 'gzip', True)
chk('有 brotli 但客户端只要 gzip → 给 gzip(不能给 br)',
enc == 'gzip', 'enc=%s' % enc)
body, enc = run(BIG, 'br', False)
chk('要 br 但没模块、且不支持 gzip → 原文(唯一正确选择)',
enc is None and body == BIG, 'enc=%s' % enc)
body, enc = run(BIG, 'br;q=0, gzip', True)
chk('客户端 br;q=0 → 给 gzip,不给 br', enc == 'gzip', 'enc=%s' % enc)
body, enc = run(BIG, '', True)
chk('不支持任何压缩 → 原文', enc is None and body == BIG, 'enc=%s' % enc)
# ---- 3. 同文件两种编码互不覆盖 ----
_comp_cache.clear()
ns['_comp_bytes'][0] = 0
saved = sys.modules.get('brotli')
m = types.ModuleType('brotli')
m.compress = lambda d, quality=None: b'BROTLI' + __import__('gzip').compress(d, 1)
sys.modules['brotli'] = m
try:
gz_body, gz_enc = _encode_body(hdr('gzip'), None, BIG)
br_body, br_enc = _encode_body(hdr('br'), None, BIG)
gz_again, gz_again_enc = _encode_body(hdr('gzip'), None, BIG)
finally:
if saved is None:
sys.modules.pop('brotli', None)
else:
sys.modules['brotli'] = saved
chk('同一份数据两种编码都能取到(键里带了编码,没互相覆盖)',
gz_enc == 'gzip' and br_enc == 'br'
and _sz(gz_body) > 0 and _sz(br_body) > 0
and gz_body != br_body and _sz(br_body) >= 6
and br_body[:6] == b'BROTLI',
'gz=%s br=%s' % (gz_enc, br_enc))
chk('gzip 结果二次命中缓存(内容与首次一致)',
gz_again_enc == 'gzip' and _sz(gz_again) > 0 and gz_again == gz_body)
chk('缓存里确实是两个独立条目', len(_comp_cache) == 2,
'实际 %d' % len(_comp_cache))
# ---- 4. 压不动的小响应 ----
# ⚠️ 用**真正不可压缩**的数据。b'x'*100 这种单字符重复,gzip 能压到 20 字节,
# 反而更小,于是「压不划算」这条判据测的其实是另一件事(我第一次就踩了)。
# 这里用 os.urandom 模拟已加密/已压缩的载荷(图片、woff、zip 都是这德性)。
import random
_comp_cache.clear()
ns['_comp_bytes'][0] = 0
rnd = random.Random(42)
small = bytes(rnd.randrange(256) for _ in range(200))
body, enc = _encode_body(hdr('gzip'), None, small)
chk('压不划算的小响应直出原文', enc is None and body == small,
'enc=%s %d→%s 字节' % (enc, len(small), _sz(body)))
# ---- 5. 缓存有上限,不会无限涨 ----
chk('缓存有条目数上限', ns['_COMP_MAX'] > 0)
chk('缓存有字节上限(24 条大响应就能吃掉上百 MB)',
ns['_COMP_MAX_BYTES'] >= 4 * 1024 * 1024,
'%d 字节' % ns['_COMP_MAX_BYTES'])
# ---- 6. 内部错误不许被兜底吞成「返回原文」 ----
# 这条是本文件最有价值的判据:压缩链里一个 NameError 曾被
# `except Exception` 吞掉,返回原文,压缩功能「完好」但从不生效。
# 表现和优化前一模一样,页面照常能开,只看响应头的判据也发现不了。
# 唯一的信号是「压缩没生效却没有任何异常」。
_comp_cache.clear()
ns['_comp_bytes'][0] = 0
saved_lock = ns['_comp_lock']
ns['_comp_lock'] = None # 故意制造 AttributeError/TypeError
try:
body, enc = _encode_body(hdr('gzip'), None, BIG)
swallowed = (enc is None and body == BIG)
except Exception:
swallowed = False
finally:
ns['_comp_lock'] = saved_lock
chk('压缩链内部错误不会被静默吞成「返回原文」',
not swallowed,
'内部错误被 except 吞掉 → 压缩静默失效,583KB 裸传却无人察觉'
if swallowed else '')
print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
sys.exit(1 if fails else 0)