跟随子进程能看到两份缺失文件,但只有必需文件决定程序失败。

strace 解释选定的系统调用边界

strace 在选定 Linux 进程与内核之间的边界上观察系统调用和信号,不需要重新构建程序,就能展示尝试访问的路径、参数、返回值和错误。应用日志漏掉操作时,这些证据很有帮助;但它不会暴露每个用户态决定,也不会自动指出哪次失败调用导致最终结果。正确诊断仍需把边界行为与程序控制流程联系起来。 坏掉的样子 父进程可能启动子进程执行真正工作。只追踪父进程,会看到等待和退出,却漏掉子进程中的文件打开失败。使用 -f 跟随新建进程可以扩大范围,但不会自动追踪所有既有兄弟进程或请求涉及的外部服务。被选中的进程集合,本身就是证据范围的一部分,不能只看到主命令被追踪就认定全部参与者都在视野内。 错误返回不一定是根因。程序经常尝试可选文件、搜索多个位置,并从预期失败中恢复。找到第一条 ENOENT,或者退出前最后一次不成功调用,都不能替代理解应用控制流程。真正需要回答的是,这个返回值如何影响正在调查的操作。相同错误名在不同分支里,可能分别代表正常探测和不可继续的条件。 过滤与计时也会改变解释。把输出限制为文件打开,就有意排除了网络及其他活动。追踪还可能改变调度和延迟,尤其影响系统调用频繁的工作负载,没有通用固定降速倍数。过滤后的记录看起来正常,不能证明被排除操作成功,也不能证明没有追踪器时会出现完全相同的竞争时序。 快速验证 把脚本保存为 straceprobe.py,在安装 strace 的一次性 Linux 环境中运行 python3 straceprobe.py。实测使用 Linux 6.12.76、Python 3.11.2 和 strace 6.1。样本启动自己的无害程序,使用临时配置路径,并要求环境允许追踪新启动子进程: from pathlib import Path from tempfile import TemporaryDirectory import subprocess,sys child=r''' from pathlib import Path import sys try:Path(sys.argv[1]).read_bytes() except FileNotFoundError:pass try:content=Path(sys.argv[2]).read_bytes() except FileNotFoundError:sys.exit(7) assert content==b'configured' ''' parent='import subprocess,sys\nchild='+repr(child)+'\nsys.exit(subprocess.run([sys.executable,"-c",child,*sys.argv[1:]]).returncode)' with TemporaryDirectory(prefix='xbcatm-strace-') as directory: root=Path(directory);optional=root/'optional.conf';required=root/'required.conf' command=[sys.executable,'-c',parent,str(optional),str(required)] def trace(name,follow): output=root/(name+'.trace') args=['strace']+(['-f'] if follow else [])+['-tt','-T','-yy','-s','200','-e','trace=openat,close','-o',str(output),'--',*command] result=subprocess.run(args,capture_output=True,text=True,timeout=10) return result.returncode,output.read_text(),result.stderr code,unfollowed,error=trace('parent-only',False) assert code==7 and str(required) not in unfollowed,(code,error) code,followed,error=trace('with-children',True) assert code==7,(code,error) def calls(text,path):return [line for line in text.splitlines() if 'openat(' in line and '"'+str(path)+'"' in line] for path in [optional,required]: lines=calls(followed,path);assert len(lines)==1 and 'ENOENT' in lines[0],lines print('failing run:',lines[0]) print('without -f the parent trace misses the child opens; following the child exposes both missing paths') required.write_bytes(b'configured') code,recovered,error=trace('recovered',True);assert code==0,(code,error) assert 'ENOENT' in calls(recovered,optional)[0] assert ' = -1 ' not in calls(recovered,required)[0] print('successful run:',calls(recovered,optional)[0]) print('successful run:',calls(recovered,required)[0]) print('a successful application can contain ENOENT; the fixture distinguishes optional lookup from required configuration') summary=root/'summary' result=subprocess.run(['strace','-f','-c','-e','trace=openat','-o',str(summary),'--',*command],capture_output=True,text=True,timeout=10) assert result.returncode==0,result.stderr text=summary.read_text();assert 'openat' in text and 'total' in text print('syscall summary:\n'+text) 应用子进程先尝试可选配置文件,允许它不存在;随后读取第二份必需配置,缺失时以状态七退出。父进程返回子进程状态。这构成一份完整失败案例,原因可以直接从样本代码查明,不依赖工作区中根本不存在的占位应用。判断追踪结果时,已知控制流程提供了可以比较的依据。 ...

二月 14, 2028 · 1 分钟 · XB Field Notes
对历史做二分查找,找出首次让测试失败的提交。

git bisect 收敛第一个坏提交

二分排查回答一个明确的历史问题:在选定历史中,某个可复现观察从哪里开始由正常变成异常?它不会仅凭给出一个提交,就自动确定根因。搜索依赖正确的端点标签和一致的中间判断。应把报告出的边界当作需要检查的证据,再确认边界两侧行为,并解释哪项变化造成了结果差异,不能把一行定位输出当作完整故障分析。 依据观察选择端点,而不是日期 至少一个已知正常提交和一个已知异常提交可以建立搜索;更多正常版本还可以进一步约束提交图。最近经过验证的正常版本,通常比不必要地选择很古老的基线更能缩小范围。日期不是搜索顺序:Git 使用祖先关系,作者时间戳不一定反映提交在图上的位置。因此,“最早”应放在这次历史边界内解释,而不是直接按墙上时间排序。 赋予标签之前,应在两个端点运行同一份复现。明确什么算目标症状,包括边界输入与相关配置。只检查任意命令有没有失败,可能把产品回归与解释器缺失、服务不可用或构建环境不兼容混在一起。一个无法测试的版本,其结论是未知,而不是自动判坏;如果输入条件没有保持一致,后续再精细的搜索也无法补回缺失的可比性。 使用干净、可丢弃的检出,或专门的关联工作树。二分通常会把 HEAD 切到选定版本并更新文件,因此应提前保存未完成工作,不能认为搜索会自动创建备份。探针和必需证据也应放在不会随历史检出而消失或改变的地方,让测试程序自身的变化与被测源码版本分开,避免把旧脚本缺失当成源码故障。 每次都判断同一个性质 观察当前版本之后,按复现结果标记正常或异常。某个版本由于与目标症状无关的原因无法分类时,可以跳过。靠近转换点的跳过可能使答案不唯一,此时应报告剩余候选集合,而不是把最先显示的对象包装成已经确定的责任提交。未知本身也是有效调查结果,不能为了快速收束而强行填上好坏标签。 简单二分直觉要求所选历史中的转换足够稳定。问题先引入、后修复、再重新引入时,对这个判断条件来说已经不是单一的单调时间线;随机失败的测试也会制造类似的误导。观察与假设矛盾时,应细化问题或区间,而不是把不一致结果塞进方便解释的故事。搜索算法不会替你决定这些不一致到底意味着什么。 合并较多时,搜索全部祖先还是只沿第一父级集成线,会改变问题本身。第一父级搜索可能找到集成分支因某次合并而出现故障的点,而不是主题分支最初引入代码的提交。同样,限制文件路径可能排除真正相关的配置或调用方变化。只有这些限制符合调查目标时才应用,不能只为了让候选数量看起来更少而缩小问题。 坏掉的样子 一个错误标签就可能排除真正边界。结束当前排查之前保存搜索日志;发现判断有误时,保留原证据,修正决定记录,重置会话后重放修正日志。重放恢复的是判断过程,不会重新执行测试,也不能证明当时的环境现在仍然有效。若探针或依赖发生变化,需要重新验证对应结论,不能把可重放等同于已复测。 找到候选后,在候选及相关父级运行准确的复现。普通线性边界应表现为候选异常、父级正常;合并提交则可能需要检查多个父级和组合行为。还应阅读补丁及受影响调用链。即便候选只是暴露了更早就存在的问题,也仍需解释因果关系,不能只凭它改变了症状,就把全部根因归给那次提交。 不要为了验证猜测,就立刻在共享或有未提交修改的分支撤销候选。修正实验应放在受控检出中,并单独审阅和测试。记录发现之后,二分重置会结束会话,通常回到开始时的检出;还要核对当前分支和版本,而不能把最后用于诊断的历史位置误认成日常开发分支,随后把新工作提交到错误位置。 快速验证 使用 Python 3 与 Git 2.28 或更新版本运行下面实验。临时仓库使用示例身份,关闭钩子及签名,排除个人 Git 配置和路由变量,没有远端。七个版本中,第三号版本把数值上限从包含等号改成不包含等号;探针专门检查边界输入十。这样测试的是一个明确行为,而不是根据提交消息或预设对象标识给版本贴标签。 import os import subprocess import sys import tempfile from pathlib import Path env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) with tempfile.TemporaryDirectory(prefix="git-bisect-boundary-") as directory: root = Path(directory) repo, log = root / "repo", root / "bisect.log" repo.mkdir() def git(*args): return subprocess.run( ["git", "-c", "user.name=Boundary Example", "-c", "user.email=boundary@example.invalid", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", *args], cwd=repo, env=env, text=True, capture_output=True, check=True, ) def verdict(): result = subprocess.run([sys.executable, "-B", "-c", "from limit import accepts; print(int(accepts(10)))"], cwd=repo, text=True, capture_output=True, check=True) assert result.stdout in ("0\n", "1\n"), result return "good" if result.stdout == "1\n" else "bad" git("init", "-q", "-b", "main") commits = [] for number in range(7): operator = "<" if number >= 3 else "<=" (repo / "limit.py").write_text(f"def accepts(n):\n return n {operator} 10\n") (repo / "revision.txt").write_text(f"{number}\n") git("add", "limit.py", "revision.txt") git("commit", "-qm", f"revision {number}") commits.append(git("rev-parse", "HEAD").stdout.strip()) endpoints = [] for revision in (commits[0], "main"): git("switch", "-q", "--detach", revision) endpoints.append(verdict()) git("switch", "-q", "main") print("endpoints-confirmed:", endpoints == ["good", "bad"]) git("bisect", "start", commits[-1], commits[0]) try: while len(git("rev-list", "refs/bisect/bad", "--not", "--glob=refs/bisect/good-*").stdout.splitlines()) > 1: git("bisect", verdict()) found = git("rev-parse", "refs/bisect/bad").stdout.strip() log.write_text(git("bisect", "log").stdout) print("boundary-identified:", found == commits[3]) finally: git("bisect", "reset") checks = [] for revision in (found, f"{found}^"): git("switch", "-q", "--detach", revision) checks.append(verdict()) git("switch", "-q", "main") print("candidate-and-parent-retested:", checks == ["bad", "good"]) try: git("bisect", "replay", str(log)) print("log-replays-boundary:", git("rev-parse", "refs/bisect/bad").stdout.strip() == found) finally: git("bisect", "reset") print("original-branch-restored:", git("symbolic-ref", "--short", "HEAD").stdout.strip() == "main" and git("rev-parse", "HEAD").stdout.strip() == commits[-1] and not git("status", "--porcelain").stdout) 五个判断都应为真。在这份线性且没有跳过的样例中,循环逐次记录正常或异常,直到只剩一个候选。最终定位第三号版本,独立复测确认候选在边界输入上失败、父级成功。重放保存的日志可以到达同一结果,但不会再次执行探针;最后恢复 main。补丁也解释了观察:上限处的相等情况被排除,正是这次回归的具体来源。 ...

十月 24, 2027 · 2 分钟 · XB Field Notes
git 内置、用于定位首个失败变更的二分查找。

git bisect 用自动探针定位回归

自动二分会反复检查历史版本,并把测试结果转成对提交的判断。在简单区间里,它通常能用近似对数级的探针次数替代大量手动检出,但前提是测试可信。包装脚本若把工具缺失或基础设施故障错误归类为产品回归,就可能给出看似明确、实际错误的提交。因此,设计判断契约本身就是调试工作的一部分,不能当作搜索外面无关紧要的准备步骤。 先确定能够复现的问题 选择一个可观察的回归,并用同一个执行器验证计划采用的正常端点和异常端点。某个发布名字被认为没问题,证据弱于在那个准确版本上实际检查成功。输入、工具链、依赖和相关环境应保持足够一致,让源码版本成为真正接受检验的变量;否则即使最后缩小到一个提交,也无法知道改变结果的究竟是代码还是环境。 启动时先给出异常版本,再给出已知正常版本。Git 会选择中间提交,并从当前检出的工作树调用探针。执行器应放在不会随历史切换而消失的稳定路径,或确保每个待测版本都包含可用实现。相对路径脚本在搜索一半时不存在,属于测试设施问题,不是产品恰好从那次提交开始损坏的证据。 按真实退出码契约映射结果 自动运行模式中,零表示正常;一到一百二十七表示异常,但一百二十五是例外,代表当前版本无法测试,需要跳过。其他状态值会中止自动搜索。这与“所有非零都说明发生回归”的简化说法不同,包装脚本必须明确区分结果,而不能把任何失败都交给 Git 当作同一种判断。 文档规定的异常区间包含常用于命令不可执行或不存在的一百二十六和一百二十七。应提前检查必需工具,并把识别出的基础设施失败映射到中止状态,例如二百。不能不看测试工具自身的含义,就原样转交所有非零返回。另外,超出可表示范围的 Shell 退出值可能被截断,因此需要选择明确、受支持的状态,不能随意用一个巨大数字表示致命错误。 跳过适用于某个确实无法测试、结论未知的版本,而不是所有构建失败。如果正在寻找的回归本来就是构建损坏,把构建失败跳过就等于丢掉主要证据。反过来,与症状无关的过时依赖,也不应自动把旧提交判坏。执行器为什么把两类情况区别对待,需要有可检查的依据,并在日志中保留相应原因。 坏掉的样子 测试输出经过管道时,最后的日志命令成功可能掩盖测试失败;清理命令也可能覆盖原状态。应先保存测试结果,再记录诊断,最后明确返回映射后的判断值。标准错误上出现了一段异常文字,并不会阻止 Git 接受意外返回的零。自动化读取的是状态契约,不会根据人类看起来很严重的日志重新推断好坏。 修改源码的测试、复用陈旧构建产物的测试,以及依赖持续变化远端服务的测试,都可能让不同版本之间失去可比性。应隔离生成文件,并保留足够日志解释每次判断。不要为了让探针可重复,就清空宽泛的用户目录;应使用可丢弃且归属明确的测试环境,把清理范围限定到自己创建的路径。 靠近转换边界的跳过,可能留下多个首坏候选。随机失败或非单调变化的判断条件,也会破坏简单边界假设;增加次数不能修复错误标签。归因之前应检查搜索日志,在报告出的边界及相关父级重新测试。提交图、跳过版本和不同构建耗时还意味着,对数级探针次数并不等于固定的实际运行时间。 快速验证 使用 Python 3 和 Git 2.28 或更新版本运行下面实验。临时仓库排除个人 Git 配置和路由变量,关闭钩子与签名,没有远端。九个合成版本编码正常、异常或不可测状态;第七号版本首次变坏,第四号版本专门触发跳过路径。执行器位于检出仓库之外,因此切换提交不会把它移走或替换成旧版本。 import os import subprocess import sys import tempfile from pathlib import Path env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) with tempfile.TemporaryDirectory(prefix="git-bisect-run-") as directory: root = Path(directory) repo, probe = root / "repo", root / "probe.py" repo.mkdir() probe.write_text('''from pathlib import Path import sys try: state = Path("state.txt").read_text().strip() except OSError: sys.exit(200) code = {"good": 0, "bad": 1, "skip": 125}.get(state, 200) print(f"probe={state} code={code}") sys.exit(code) ''') def git(*args, check=True): return subprocess.run( ["git", "-c", "user.name=Bisect Example", "-c", "user.email=bisect@example.invalid", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", *args], cwd=repo, env=env, text=True, capture_output=True, check=check, ) git("init", "-q", "-b", "main") commits = [] for number in range(9): state = "skip" if number == 4 else ("bad" if number >= 7 else "good") (repo / "state.txt").write_text(state + "\n") git("add", "state.txt") git("commit", "--allow-empty", "-qm", f"revision {number}") commits.append(git("rev-parse", "HEAD").stdout.strip()) codes = [] for revision in (commits[0], "main"): git("switch", "-q", "--detach", revision) codes.append(subprocess.run([sys.executable, str(probe)], cwd=repo, text=True, capture_output=True).returncode) git("switch", "-q", "main") print("endpoints-verified:", codes == [0, 1]) git("bisect", "start", commits[-1], commits[0]) try: run = git("bisect", "run", sys.executable, str(probe)) print("skip-observed:", "probe=skip code=125" in run.stdout) print("first-bad-correct:", git("rev-parse", "refs/bisect/bad").stdout.strip() == commits[7]) finally: git("bisect", "reset") git("bisect", "start", commits[-1], commits[0]) try: before = git("bisect", "log").stdout fatal = git("bisect", "run", sys.executable, "-c", "raise SystemExit(200)", check=False) print("fatal-did-not-classify:", fatal.returncode != 0 and git("bisect", "log").stdout == before) finally: git("bisect", "reset") print("original-checkout-restored:", git("symbolic-ref", "--short", "HEAD").stdout.strip() == "main" and git("rev-parse", "HEAD").stdout.strip() == commits[-1] and not git("status", "--porcelain").stdout) 五个观察都应为真。执行器先确认两个端点,搜索中实际遇到跳过码,仍然正确定位第七号版本。第二次搜索故意返回二百,应中止且不向二分日志增加新的好坏判断。两次搜索最后都恢复原分支与原末端,并保持工作树干净,避免把仍处于排查状态误当作已经回到正常开发位置。 ...

七月 7, 2027 · 2 分钟 · XB Field Notes