This commit is contained in:
2026-09-12 14:19:56 +08:00
commit 87ab0b794b
130 changed files with 30042 additions and 0 deletions

257
engine/runner.py Normal file
View File

@@ -0,0 +1,257 @@
import argparse
import io
import shutil
import sys
import tempfile
import traceback
from engine.harness import calibrate_cprofile_overhead, collect_environment, load_source
from engine.schema import SCHEMA_VERSION, AnalysisResult, Calibration, FlameNode, WallTime
from engine.structure import profile_and_measure
# Result 文件大小防护(防用户脚本异常把磁盘撑爆)
# - error.message 上限 1KB:1KB 一般足够覆盖正常异常说明
# - error.traceback 上限 8KB:100 帧左右 traceback 的体量,UI 可读范围
# - functions 列表上限 5000:UI 表格默认只看前 200,再多只是给后续解析加负载
_MAX_ERROR_MESSAGE_BYTES = 1024
_MAX_TRACEBACK_BYTES = 8 * 1024
_MAX_FUNCTIONS = 5000
def _truncate_text(text: str, max_bytes: int) -> str:
"""把文本按 UTF-8 字节截断到 max_bytes 以内,补一个明确截断标记。
直接 [:max_chars] 在多字节字符中间切会破坏 UTF-8,UI 解析挂。encode → 截
→ 走 errors='ignore' 让不完整尾部自然掉落 → decode,保证结果仍是合法 UTF-8。
"""
if not text:
return text
encoded = text.encode("utf-8", errors="replace")
if len(encoded) <= max_bytes:
return text
truncated = encoded[:max_bytes].decode("utf-8", errors="ignore")
# 留 64 字节给标记(总长仍 ≤ max_bytes)
suffix = f"\n\n[... 截断:共 {len(encoded)} 字节,仅保留前 {max_bytes} 字节 ...]"
# 如果 suffix 太长导致总超,再砍一次
return (truncated + suffix)[:max_bytes]
def _cap_functions(functions: list, flame: FlameNode | None) -> tuple:
"""截断 functions 列表到 _MAX_FUNCTIONS,并把超出部分累加到 flame 的 root。
为什么不只截 functions 不动 flame?
- flame 是 UI 看到的"时间分布",丢了 functions 里那些小函数会让 flame
总和 < 用户报的总耗时,UI 显示出现 100% 但 sum 不到 80% 的诡异情况。
- 把被丢函数的 tottime 加到 root.value 上,UI 那边仍按 flame 内部占比显示,
多出来的部分归到 root 节点(最大最宽的 tile,用户视觉上只是「其他」变
大了一点 —— 总和不撒谎。
"""
if len(functions) <= _MAX_FUNCTIONS:
return functions, flame
kept = functions[:_MAX_FUNCTIONS]
dropped_tottime = sum(f.tottime for f in functions[_MAX_FUNCTIONS:])
if flame is None:
flame = FlameNode(name="root", value=dropped_tottime, children=[])
else:
flame.value += dropped_tottime
return kept, flame
def _compute_wall_time(instrumented_sec: float, calibration: Calibration) -> float:
"""用校准 ratio 折算 instrumented 时间为估计的干净耗时。
思路:cProfile 是插桩式的,wall-time 会被膨胀 ratio 倍(instrumented / clean);
用同一段 tight-loop 测出 ratio 后,把用户的 instrumented 时间除回去,就是
「去掉 cProfile 开销后的估计耗时」。
边界:calibration.ratio 是 0 / 负数 / NaN 时直接信 instrumented 时间 —— 跟
calibrate_cprofile_overhead 在「校准耗时过短」时回退 ratio=1.0 的兜底策略
保持一致,UI 显示真实测得的 instrumented 时间而不是除以无效值。
"""
ratio = calibration.ratio
# `ratio == ratio` 排除 NaN;`ratio > 0` 排除 0 / 负数
if ratio == ratio and ratio > 0:
return instrumented_sec / ratio
return instrumented_sec
def _cap_result(result: AnalysisResult) -> AnalysisResult:
"""给 result.json 上一道防线,防止异常情况把磁盘/IPC 通道撑爆。
触发场景:
- 用户代码 raise 一个带 50MB 文件内容的 ValueError → error.message 撑爆
- 用户代码触发深递归异常,Python 自动 dump 几千层 traceback → 撑爆
- 用户脚本 import 一个庞大的库(numpy/pandas)scope=all 把整棵树都归因 →
38MB+ 的 functions 列表
截断而非拒绝:UI 仍能看到「有错」「发生了什么」,只是具体细节截断;functions
截断后仍按 tottime 排序,前 5000 一定是最值得用户关注的热点。
"""
if result.error:
msg = result.error.get("message")
if isinstance(msg, str) and msg:
result.error["message"] = _truncate_text(msg, _MAX_ERROR_MESSAGE_BYTES)
tb = result.error.get("traceback")
if isinstance(tb, str) and tb:
result.error["traceback"] = _truncate_text(tb, _MAX_TRACEBACK_BYTES)
if result.functions:
result.functions, result.flame = _cap_functions(result.functions, result.flame)
return result
def _progress(phase, pct):
# 前导 \ntqdm / 进度条类库会写"working..."到 stderr 但不换行,
# 直接接 PROGRESS 会拼成 "working...PROGRESS running 10",主进程正则匹配挂、
# 进度事件掉一档且把工程协议行混进 stderrTail 让用户看到 \r 进度条残片。
print(f"\nPROGRESS {phase} {pct}", file=sys.stderr, flush=True)
def _emit(result: AnalysisResult, out_path=None):
"""发出结果。
out_path 给定时结果写入独立文件stdout/stderr 就完整留给用户代码 ——
否则用户代码里一句 sys.stdout.write("done") 或 print(..., end='') 就会和
JSON 挤在同一行,主进程按"最后一行"取 JSON 时解析失败,
用户看到的却是"结果解析失败"。不给 out_path 时退回 stdout直接跑 CLI / 老测试)。
"""
payload = result.to_json()
if out_path:
with io.open(out_path, "w", encoding="utf-8", newline="\n") as f:
f.write(payload)
else:
print(payload, flush=True)
def _base(env, config, status, error=None, wall_time=None, functions=None, flame=None, calibration=None):
return AnalysisResult(
schemaVersion=SCHEMA_VERSION,
environment=env,
config=config,
status=status,
error=error,
wallTime=WallTime(seconds=wall_time, unit="s") if wall_time is not None else None,
functions=functions or [],
flame=flame,
calibration=calibration,
)
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("--script", required=True)
ap.add_argument("--out", default=None,
help="结果 JSON 的输出文件;不给则打到 stdout")
ap.add_argument("--scope", choices=["user", "all"], default="user",
help="剖析范围user默认只归因用户脚本里的函数"
"all 包含所有非 cProfile 内部帧(标准库 + 第三方包 + 用户代码),"
"让耗时可以下钻到 import 的包里。")
ap.add_argument("--workdir", default=None,
help="校准文件落盘的临时目录;不给则 runner 自己 mkdtemp。"
"profiler-service 一般传过来(它创建的 pyprof-xxx),"
"直跑 CLI 不传也行。")
ap.add_argument("--hide-internal", dest="hide_internal",
action=argparse.BooleanOptionalAction, default=True,
help="过滤掉测试代码(tests/ / test_*.py / _pyrof_calib 等)+ "
"tottime=0 的量化噪声帧。默认 on,用户原话:「软件内部的测试部分"
"默认百分百过滤掉」。--no-hide-internal 关掉这条过滤,看完整数据。")
args = ap.parse_args(argv)
# 统一走这个 emit结果去 --out 指定的文件stdout 留给用户代码。
# 每条 emit 之前过 _cap_result 给 result.json 上一道防线 —— error.traceback /
# error.message / functions 列表都设硬上限,防止用户异常把磁盘 / IPC 撑爆。
def emit(result):
_emit(_cap_result(result), args.out)
env = collect_environment()
config: dict = {}
# 版本预检:主进程的 validateInterpreter 已经挡过一层,这里是纵深防御——
# 用户可能绕过 UI 直接调引擎,或 PATH 上的 python 在探测后被换掉。
# 低于 3.9 时后面的 list[X] 泛型注解会直接 SyntaxError拿不到结构化错误。
if sys.version_info < (3, 9):
got = ".".join(str(p) for p in sys.version_info[:3])
emit(_base(env, config, "runtime_error",
error={"type": "PythonTooOld",
"message": f"引擎需要 Python 3.9 或更高版本,当前为 {got}"}))
return
# 一次 IO 拿到 src语法预检和后续 profile 都用这份文本
# 之前没 try/except临时目录被删 / 路径权限不够 → 进程崩,无 JSON 输出 → UI 显示 "引擎无输出"
# UnicodeDecodeError 不是 OSError 的子类:二进制文件 / 非 UTF-8 脚本会绕过这层保护再次崩溃。
try:
src = load_source(args.script)
except (OSError, UnicodeDecodeError) as e:
emit(_base(env, config, "runtime_error",
error={"type": type(e).__name__, "message": f"无法读取脚本: {e}"}))
return
# Syntax precheck (用文本而非再读一次)
# compile() 还会在源码含 null 字节时抛 ValueError也一并按 syntax_error 归类
# 把 code object 留起来 —— profile_and_measure 还需要再 exec 一次,
# 直接复用这份省一次 read + compile。
try:
code = compile(src, args.script, "exec")
except (SyntaxError, ValueError) as e:
emit(_base(env, config, "syntax_error", error={"type": type(e).__name__, "message": str(e)}))
return
# v4 单跑架构:校准 + 用户代码 under cProfile 一次拿到归因 + instrumented 时间。
# 跟 v3 的「先裸跑测 wall_time 再 cProfile 跑」相比,
# 用户脚本的副作用(plot / file write / etc)只发生一次,语义更直观。
# workdir 优先用 caller 传的(profiler-service 已经 mkdir pyprof-xxx 落 _pyrof_calib.py
# 和用户脚本);caller 不传则自己 mkdtemp —— 这种情况跑完自己清掉。
workdir = args.workdir
workdir_owned = False
if not workdir:
workdir = tempfile.mkdtemp(prefix="pyprof-")
workdir_owned = True
try:
try:
_progress("calibrating", 5)
# 校准脚本落在 caller 提供的 workdir 里 —— profiler-service 已经 mkdir
# pyrof-xxx 装用户脚本,把 _pyrof_calib.py 放一起能让 rmtree(workdir) 一次
# 清干净。runner 自己 mkdtemp 时(直跑 CLI 场景)workdir_owned=True,
# calibration 拿到的是同一个目录,临时文件一起被 finally 兜底删。
calibration = calibrate_cprofile_overhead(workdir)
_progress("running", 30)
st, instrumented = profile_and_measure(
args.script, scope=args.scope, hide_internal=args.hide_internal,
code=code, src=src,
)
wall_time = _compute_wall_time(instrumented, calibration)
_progress("done", 100)
emit(_base(env, config, "ok",
wall_time=wall_time,
functions=st.functions,
flame=st.flame,
calibration=calibration))
return
except SystemExit as e:
# sys.exit() 抛 SystemExitBaseException 的子类)—— 不被下面的 except BaseException 捕获,
# 子进程直接退出无 JSONUI 就会显示 "引擎无输出 (exit N)"。转成 runtime_error。
# e.code 默认 None无参 sys.exit()),文案要单独处理,否则会出现"sys.exit(None)"
code = e.code if isinstance(e.code, int) else (0 if e.code is None else f"非整数 {e.code!r}")
emit(_base(env, config, "runtime_error",
error={"type": "SystemExit", "message": f"用户代码调用 sys.exit({code})"}))
except BaseException as e:
# KeyboardInterrupt / GeneratorExit / 自定义 BaseException 子类 以及
# 普通 Exception 一并兜底 —— 之前再写一条 `except Exception` 是 dead code
# BaseException 是 Exception 的父类,先匹配的赢)。父进程端有 cancel +
# 进程树 kill 在管 Ctrl+C,这里只负责兜底写 JSON。
emit(_base(env, config, "runtime_error",
error={"type": type(e).__name__, "message": str(e),
"traceback": traceback.format_exc()}))
finally:
# runner 自己 mkdtemp 的目录要自己清 —— profiler-service 已经传 --workdir 时
# 整个 workdir 由 caller 管(主进程清理临时目录的逻辑在 before-quit),这里只兜
# 「直接 python -m engine.runner」的直跑场景。
if workdir_owned:
shutil.rmtree(workdir, ignore_errors=True)
if __name__ == "__main__":
main()