标签: openpilot

  • tinygrad GPU 内存:分配器、页表与映射所有权

    结论:tinygrad 的 GPU 内存层把职责切成三层——分配地址、建立映射、拥有资源——并把「映射建立」和「硬件可见」明确分开。通用函数只保证 PTE 文本与虚拟地址记账,真正的 TLB 可见性由设备专用钩子 on_range_mapped() 或外层队列承担。valloc() 还存在失败路径:物理页分配失败分支释放了物理页,却没有对应释放已取得的 VA,静态上存在失败后保留地址配额的条件性风险。

    三层职责

    基线是固定归档 memory.py 及其 PCI、AMD/NV/RDMA 调用方;本文解析计算后端的内存基础设施,不代表 openpilot 默认推理路径必经每种后端。仅静态阅读与小型无设备探针,未运行外部 GPU 页表测试,也未验证目标硬件或整模型。

    分配地址由 BumpAllocator.alloc()(固定范围内的线性/环形偏移)和 TLSFAllocator.alloc()(两级桶找空闲块、按请求对齐并拆块,free() 插回并合并相邻空闲块)负责。MemoryManager 持有全局 VA TLSF,以及每设备 boot、页表和普通物理内存池。建立映射由 VirtMapping(记录 VA、大小、物理段、地址空间和缓存属性)和 PageTableTraverseContext.next() 完成;map_range() 先检查目标 PTE 未占用,再写 PTE 并调用设备钩子。拥有资源则收敛到 valloc()/vfree()。

    在这三层之上,PCIIfaceBase.alloc() 用同一套机制分配 VRAM 或映射锁页系统内存,PCIIfaceBase.map() 则把本机 host 或同一 peer group 的 GPU 缓冲映射到设备 VA。也就是说,插件看到的「一块可读写内存」背后同时牵扯虚拟地址池、物理页池和页表遍历三种资源,任一层记账不一致,都可能表现为越界或数据不一致,而不是立刻报错。

    重要函数与行为契约

    函数 输入/输出 需要核对的契约
    BumpAllocator.alloc() size, alignment 到地址 wrap=True 时满池重置指针,调用方需保证旧引用已失效;wrap=False 才在空间不足时报错。
    TLSFAllocator.alloc() 请求尺寸/显式 align 到地址 block_size 是最小分配量,不是默认返回地址对齐值;默认 align=1。
    map_range() VA、物理段、地址空间/缓存位到 VirtMapping size == sum(segment sizes),拒绝已占用 PTE;实际 PTE 格式由设备 pt_t 实现。
    unmap_range() VA/长度到清除 PTE 对未映射 PTE 断言;是否需额外 TLB shootdown 取决于设备钩子/调用方,不能只凭通用函数认定完成。
    valloc()/vfree() 分配/释放 VRAM 及其映射 GMMU=0 改为 identity VA;普通路径分配分段物理页并建页表。

    此外,PageTableTraverseContext.level_down()/level_up() 按 create_pts/free_pts/inspect 模式创建、释放或只读遍历,释放模式要求映射存在;alloc_vaddr() 的请求使用 max(最高置位 2 的幂, align) 对齐,不同设备共享类级 VA 池。

    页表遍历按页覆盖范围、对齐和设备支持的大页向下建表或向上归并;TLSF 侧的 _insert_block()、_split_block()、_merge_block() 依赖 blocks 中保存的前后邻接与空闲位,所以调用方必须成对管理 alloc/free,否则空闲桶与真实占用会失真。

    可复现的边界与测试状态

    边界 源码可见事实
    Bump 超大请求 wrap=True 时只在第一次比较中决定回绕,没有再检验 size <= capacity。无设备探针 BumpAllocator(16).alloc(32) 返回地址 0,内部 ptr=32 > size=16;属通用分配器边界,不等于默认车端已触发。
    TLSF 对齐语义 探针 TLSFAllocator(128, block_size=16) 连续 alloc(20), alloc(35) 返回 0,20;显式 align=16 则返回 0,32。
    VA 失败回滚 valloc() 取得 VA 后,物理页分配失败分支会释放已分配的物理页,但没有对应 va_allocator.free(va);map_range() 抛错时也未在 valloc() 层回收。
    映射与缓存 map_range() 对 PTE 重叠做预检查,但硬件 TLB 可见性由设备专用 on_range_mapped() 或外层队列承担。
    测试状态 external_test_tlsf.py 把 block_size 当默认对齐、使用旧的 start_addr 构造/属性;本轮运行 8 项,5 通过、1 失败、2 错误。不能把这 3 项直接归因于运行时内存损坏。

    AddrSpace.PHYS/SYS/PEER、uncached/snooped 不是可随意互换的插件参数;错误声明可能导致一致性故障,不能用 Python 层写读一致代替 GPU 校验。未运行的硬件测试方面,external_test_am.py 涉及 AMD 页表/设备,external_fuzz_tlsf.py 默认分配 1 GiB 做长时间 fuzz,本轮均未执行。

    对自制硬件及插件的约束

    计算设备插件应声明可用 VA 区间、页大小/大页能力、物理内存区域、对齐、缓存与 snoop 位、host/P2P 映射能力,以及 map → submit → synchronize → unmap → free 的所有权顺序。验证路径优先用无设备分配器不变量测试和 mock 页表故障注入;再在目标板验证映射重叠拒绝、OOM 回滚、TLB 可见性、跨设备 P2P 与反复热重启。内存插件只能影响计算数据通路,不得自行取得车辆 CAN 发送或 panda 安全授权。

    验收顺序也应与所有权顺序一致:先声明资源边界,再跑无设备不变量测试,最后才上目标板做映射重叠拒绝、OOM 回滚、TLB 可见性、跨设备 P2P 与反复热重启。任何一次「分配成功」都不能被解读为对车辆总线或安全策略的授权,因为内存层与车辆控制本就是两个相互独立的边界。

    对自制硬件而言,更稳妥的做法是把内存层当作可隔离、可回滚的计算资源服务,而不是与车辆控制共享状态的可信组件。

    常见问题

    tinygrad 的 map_range() 成功就等于映射对 GPU 可见了吗?

    不等于。map_range() 只做「检查 PTE 未占用、写 PTE、调用设备钩子」这一层,硬件 TLB 可见性由设备专用 on_range_mapped() 或外层队列承担;unmap_range() 是否需额外 TLB shootdown 也取决于具体设备钩子/调用方,不能只凭通用函数认定完成。

    valloc() 失败时地址配额会被回收吗?

    按源码存在条件性风险。valloc() 取得 VA 后,物理页分配失败分支会释放已分配的物理页,但没有对应 va_allocator.free(va);map_range() 抛错时也未在 valloc() 层回收 VA/物理页。静态上存在失败后保留地址配额的可能,需用故障注入验证具体设备恢复行为。

    BumpAllocator 的 wrap=True 有什么坑?

    wrap=True 时满池会重置指针,调用方需保证旧引用已失效;且源码只在第一次比较中决定回绕,没有再检验 size 是否小于等于 capacity。无设备探针 BumpAllocator(16).alloc(32) 返回地址 0、内部 ptr=32 大于 size=16。对永久 VA 或环形命令缓冲,应在调用侧/分配器层拒绝超大请求。

    为什么不能把 uncached/snooped 当普通插件参数?

    因为 AddrSpace.PHYS/SYS/PEER、uncached/snooped 决定映射的缓存与一致性属性,源码指出它们不是可随意互换的插件参数,错误声明可能导致一致性故障;也不能用 Python 层的写读一致代替 GPU 校验。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/TINYGRAD_GPU_MEMORY_ALLOCATOR_PAGETABLE_OWNERSHIP_DEEP_DIVE.md(取用了三层职责、函数契约表、Bump/TLSF/VA 回滚边界与自制硬件约束)。
    • 项目 02 —— openpilot-research/TINYGRAD_AMD_DEVICE_IP_BOOT_PARTIAL_RESET_LIFECYCLE_DEEP_DIVE.md(仅用于确认设备启动/复位专题的接续关系,未取用其未公开内容)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • 设备功耗验收:hwmon 增量不是逐进程瓦特计

    结论:设备功耗测试 test_power_draw.py 测的是同一 hwmon 节点读数的阶段增量,不是逐进程独立瓦特计:每个阶段把当前 get_power(1) 均值与前一阶段比较,prev 每阶段更新。因此它测到的是「依次加负载后的边际电源通道读数」,可能混入相机预热、GPU 频率、风扇、后台进程或上个阶段尚未稳定的影响。

    本文基于固定源码快照的静态分析及五项原测试类方法/假时钟探针;test_power_draw.py 依赖特定硬件、固定 sysfs 功率节点、真实进程和消息总线,本次没有运行设备测试、读取 hwmon、启动相机/模型或修改设备电源状态。底层功率采样的窗口边界属于相邻专题。

    硬件读数与函数索引

    read_power() 读取固定的 hwmon 功率节点,数值除 1e6 转为瓦特;节点缺失或读数不合法直接抛异常,没有探测或热插拔恢复。sample_power() 用 Ratekeeper(123) 读取约 123 × seconds 个样本,未返回每个样本时间戳;get_power() 对指定秒数样本取算术平均,测试基线默认 5 秒,候选窗口按每次 1 秒测。

    函数/数据 静态契约
    read_power() 读固定 hwmon 节点,除 1e6 转瓦;缺失或不合法直接抛异常,无恢复
    sample_power() Ratekeeper(123) 读约 123 倍秒数样本,未返回每样本时间戳
    get_power() 对指定秒数样本取算术平均;基线默认 5 秒,候选窗口每次 1 秒
    Proc 与 PROCS 进程名、期望功率增量、需要消息名及 np.isclose 容差;顺序为 camerad、modeld、dmonitoringmodeld、encoderd
    valid_power_draw() 比较同一 hwmon 节点读数的增量,不是进程独立瓦特计
    get_expected_messages() / valid_msg_count() 各服务标称频率乘 2 秒求和;仅比较总条数
    get_power_with_warmup_for_target() 为当前进程预订其消息并先清队列;最多 30 秒反复测 1 秒功率,保留最近两个样本及消息数
    test_camera_procs() 先测基线再逐个启动进程,当前平均功率作为下一阶段 prev;函数名虽指 camera,实际涵盖模型和编码器

    阶段增量与容差

    每一阶段比较 get_power(1) 的最近两次均值与前一阶段读数,prev 在每阶段完成后更新。测试既不把单个进程功耗隔离,也不减去同一时刻的独立空载对照。绝对容差分别是 camerad 0.4 W、modeld 0.2 W、dmonitoringmodeld 0.35 W、encoderd 0.12 W;相对容差默认 5%,实际 np.isclose 取两者合成范围。

    阶段进程 期望功率增量 绝对容差 消息门
    camerad 1.65 W(三相机消息) 0.4 W 按标称频率合计条数
    modeld 1.5 W(modelV2) 0.2 W 按标称频率合计条数
    dmonitoringmodeld 0.65 W(driverStateV2) 0.35 W 按标称频率合计条数
    encoderd 0.23 W(无消息门) 0.12 W msgs=[],期望数与实收数都为 0,恒通过

    valid_msg_count() 允许约 2% 相对误差或绝对 2 条,不逐主题核对;某一路缺失若被其它路超量抵消,仍可能通过总数门。

    测试真实覆盖与失效路径

    TestPowerDraw 继承 OpenpilotTestCase,标记为硬件测试。其 setup_method() 写入演示 CarParams;继承的 setUp() 会初始化硬件、关闭省电并终止 athena,teardown_method() 和 run() 的 finally 还会做 manager 清理。mock_messages(['deviceMotion']) 在测试期间另起后台发布线程。因此这项测试不是无副作用的算法单元测试。

    失效/边界 行为 后果
    30 秒超时退出 仍返回最后的 now/msg_counts 返回的 warmup_time 不是成功标志
    首样本就越过截止 循环在不足两个样本后退出 返回变量尚未赋值而抛 UnboundLocalError
    encoderd 的 msgs=[] 期望数与实收数都为 0 消息门恒通过,只靠功率增量间接判断
    合计消息门 只比较总条数 单主题缺失可被其它主题超量抵消

    隔离执行原 TestPowerDraw 方法后,假服务各 10 Hz、两秒窗口的 left=0 与 right=40 仍通过 40 条合计消息门;空主题列表也通过。假时钟在第一份功率样本返回后越过 30 秒,使 now/msg_counts 尚未赋值而抛 UnboundLocalError。已有两份样本但功率不达标时,超时返回最近两份均值而非错误;若两份样本达标则提前退出。该探针未执行 test_camera_procs(),不能证明真实服务频率、传感器功率或 manager 清理行为。

    manager_cleanup() 在测量尾部、测试 teardown 及继承的 run() 清理路径多次调用,主要作用是停止所有受管进程;若中途抛异常,测试体末尾的清理不会到达,但外层测试辅助类的 finally 仍尝试清理。它不能回滚硬件电源、温度或持久参数的外部状态。

    面向硬件插件的验收建议

    自研计算板/相机/推理加速器要把功耗基线、传感器/模型负载和安全 MCU 电源域分开测量,记录电压、电流、温度、频率、环境与样本时间戳,并对稳态/启动峰值/异常回退分别设门。

    1. 插件的「功率达标」不能替代图像帧有效性、模型输出数值一致性、消息新鲜度或 CAN 发送安全。
    2. 固定路径 hwmon 工具只适用于已知设备布局,不应作为跨硬件插件通用驱动接口。
    3. 验收应把电源通道、消息条数和进程启动结果分开记录,避免用单一通过条件覆盖多类风险。

    验证范围上,本专题已静态核对测试全部函数、hwmon 工具、消息模拟器和 manager 清理链,但未读取实际电源节点、运行硬件或统计噪声分布;探针只复现 Python 控制流,不表示已在具体设备上测得这些失效。

    常见问题

    这个测试能测出单个进程的独立功耗吗?

    不能。valid_power_draw() 比较的是同一 hwmon 节点读数的增量,不是进程独立瓦特计;每阶段把当前均值与前一阶段读数比较,可能混入相机预热、GPU 频率、风扇、后台进程或上个阶段尚未稳定的影响。

    warmup_time 达标就代表测试成功吗?

    不是。get_power_with_warmup_for_target() 到 30 秒退出后仍返回最后的 now/msg_counts,再由测试函数的最终断言决定失败;返回的 warmup_time 不是成功标志。

    为什么 encoderd 阶段的消息门形同虚设?

    因为该阶段 msgs=[],期望数与实收数都为 0,消息门恒通过;这一阶段只靠功率增量和进程启动无异常间接判断,没有核对编码帧、输出文件或码流有效性。

    消息条数达标就能说明各服务都正常吗?

    不能。门控只比较所列服务的合计条数,某一路缺失若被其它路超量抵消仍可能通过;valid_msg_count() 允许约 2% 相对误差或绝对 2 条,不逐主题核对。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/POWER_DRAW_HW_MONITOR_PROCESS_INCREMENT_TEST_DEEP_DIVE.md(取用了 hwmon 读数索引、阶段增量与容差表、失效路径及五项探针结论)。
    • 项目 02 —— openpilot-research/POWER_SAMPLE_WAIT_WINDOW_CONTRACT_DEEP_DIVE.md(仅用于确认底层功率采样的窗口边界,未取用其未公开内容)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • lateral_maneuversd:第 41 次就绪才启动与末帧有效曲率

    结论:横向实验需在速度偏差 < 0.7 m/s、latActive、绝对曲率 < 0.004 1/m、绝对 roll < 0.12 rad 全部成立后才计数,且严格大于 40(理想至少 41 个合格循环)才进入活动并记录基线曲率;就绪计数失败时每轮只减一,所以并非严格连续两秒。活动期只对驾驶员转向/油门输入和车速偏差过大触发 reset(),latActive=False、曲率或 roll 越界本身不会撤销动作。完成保持期前 19 次循环计划无效,第 21 帧会短暂发出一帧有效基线曲率。以下为固定快照 c8fb906 的静态阅读与隔离探针,未运行回放、CAN、硬件或实车。

    关键函数与字段

    该进程是开发实验进程,不应被当作常规横向规划器。下表按入口列出源码行为与契约边界。

    入口 源码行为 契约边界
    进程条件 / 配置项 started && LateralManeuverMode 运行实验进程;参数在 manager 启动和下线切换清理 它与纵向的 plannerd 不互斥;UI 令实验开关互斥只是配置层约束
    _sine_action() / MANEUVERS 构造 20/30 mph 的阶跃、0.5 Hz 正弦和十段交替 jitter,共八组,每组 repeat=2 Action 只断言时间与加速度数组等长,不保证时间节点单调、有限或有界
    Maneuver.get_accel() 速度与初速差小于 0.7 m/s、latActive、绝对曲率小于 0.004 1/m、绝对 roll 小于 0.12 rad 时计数;不满足时计数每轮减一 严格大于 40;失败仅减一,并非严格连续两秒;活动后不再以 ready 作前置条件
    Maneuver.reset() 清动作帧/序号/活动标志,再清就绪计数 重复轮次须重新积累就绪计数;_repeated、_finished 不由 reset() 清除
    main() 中止分支 转向按压、油门按压使 aborted_cnt 设为名义一秒并 reset();活动时车速偏差大于 0.7 m/s 也重置 未把 latActive=False、曲率/roll 超阈值、订阅输入无效或模型失联列为活动中止条件
    main() 发布分支 每轮发布 alertDebug.valid=True 和 lateralManeuverPlan;计划仅在有动作、active 且 complete_cnt==0 时有效 有效位不取 sm.all_checks();sm.update() 默认 100 ms 超时也可返回,动作帧与提示计数按 DT_MDL=0.05 推进
    Controls.state_control() / SubMaster.update_msgs() 只看该主题最近一次收到的 valid 来选择实验曲率,随后由 clip_curvature() 限幅 发布进程停发后,上次有效计划可能被继续选择;订阅者未在该分支检查 updated、alive 或年龄

    就绪计数与中止边界

    进入活动要同时满足四个严格条件,并累计超过 40 次。get_accel() 在满足时计数、不满足时只减一,因此短暂中断不会清零计数,也就不是严格的「连续两秒合格」。此外,内置实验曲线不是车型认证极限,Action 只断言时间与加速度数组等长,不保证插件提供的时间节点单调、有限或有界。

    项目 阈值 / 计数
    速度与初速差 小于 0.7 m/s
    绝对曲率 小于 0.004 1/m
    绝对 roll 小于 0.12 rad
    就绪计数 严格大于 40(理想至少 41 个合格循环)
    完成提示保持 int(1/DT_MDL)=20 次循环
    动作时间步 DT_MDL=0.05

    中止条件并不覆盖全部「异常」:main() 的中止分支只把转向按压、油门按压,以及活动期间车速偏差大于 0.7 m/s 作为触发。也就是说,latActive=False、曲率或 roll 超出就绪门本身,不会重置动作计划。这是进程状态事实,不代表失活时执行器仍会转向。

    完成保持期的边界

    继承的 _step() 在最终轮次结束时设置 _finished=True 和 _run_completed=True,但没有清 _active。main() 因 _run_completed 把 complete_cnt 设为 int(1/DT_MDL)=20。保持期前 19 次循环计划无效;最后一次循环先把计数从 1 减至 0,再执行 plan_send.valid = maneuver is not None and maneuver.active and complete_cnt == 0,因此按当前控制流会发送一帧有效的基线曲率(本轮 accel 初始值 0),然后才把完成的 maneuver 清空。

    五项原 main() 假发布器探针确认:正常活动时目标为 baseline + accel / vEgo²;最终保持序列中,前 20 帧无效,第 21 帧短暂有效且目标等于基线曲率,下一帧清空动作并提示全部完成。驾驶员转向输入和活动期间车速偏差超限会在计算前调用 reset();仅 latActive=False、曲率或 roll 超出就绪门本身不会触发该复位。这里是隔离消息构造证据,不是已观测到的实际转向脉冲;controlsd 可能在不同调度相位收到该帧,且仍有 latActive、曲率限幅、车型和 panda 门。离线报告生成器按最后一条 valid 计划确定截取边界,这一额外有效帧也可能改变报告边界。

    对硬件与插件化的要求

    横向实验插件必须定义独立的计划租约、真实采样时戳、计划年龄上限、曲率单位/符号和适用车速;宿主统一处理 valid、存活和失效后的模型回退。armed → active → aborted/completed 状态应显式清理;横向失活、车速/曲率/roll 越界、输入失联和驾驶员干预的中止策略须分别定义。提示保持计数不能决定计划有效性。

    动作曲线、基线曲率和加速度到曲率的转换须按目标车型及硬件版本验证;回放重点覆盖第 41 次就绪、就绪中断、活动中失活、重复轮次、最终保持帧和发布者停发。当前归档源码目录未见针对这些状态转移或最终保持帧的专门原仓库自动化测试;本篇十项隔离探针补充了部分控制流证据,未运行真实消息总线、动态回放或实车验证。

    常见问题

    为什么是第 41 次而不是第 40 次才启动?

    因为 get_accel() 用严格大于 40 判定,理想情况下至少 41 个合格循环才记录基线并开始;失败时计数只减一而不清零,所以也并非严格连续两秒。

    横向失活会自动中止实验吗?

    不会自动中止。main() 的中止分支只把转向按压、油门按压和活动时车速偏差大于 0.7 m/s 列为中止;latActive=False、曲率/roll 超阈值、输入无效、模型失联未被列为活动中止条件。

    实验结束后会多发一帧转向吗?

    按当前控制流,完成保持期前 19 次循环计划无效,最后一次先把计数从 1 减到 0 再判定 valid,因此会发出一帧有效的基线曲率(本轮 accel 初值 0),随后清空。这是隔离消息构造证据,不是已观测到的实际转向脉冲。

    发布者停发后计划会立刻失效吗?

    不一定。controlsd 只看该主题最近一次收到的 valid,而 SubMaster 的 alive 独立按收包时间计算;发布进程停发后,上次有效计划可能被继续选择。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/LATERAL_MANEUVER_STATE_MACHINE_DEEP_DIVE.md(横向实验的状态机、阈值与完成保持帧)
    • 项目 02 —— openpilot-research/LATERAL_TARGET_ACTUATION_DEEP_DIVE.md(横向执行链,目标曲率下游的独立门,作为本文的补充阅读)

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • pandad 主机复位循环:reset/BOOT0 交替与无退避重试

    结论:pandad 的 Python 包装器是一台「复位—恢复—刷写—启动」的循环机:偶数轮常规复位、奇数轮带 BOOT0 的 ROM 恢复,恢复所有 DFU 设备后要求运行态 panda 恰好一块。它的失效模式不是崩溃而是「沉默继续」——GPIO 写异常被吞掉、recover() 的布尔返回值未检查、无设备时无退避地快速重试,而包装器存活并不等于 CAN 已就绪。

    包装器时序:心跳丢失与每轮枚举

    启动时,包装器遍历已发现的 panda,读取内部板 heartbeat_lost;若为真,写持久键并记录日志。此处构造的 Panda() 默认 disable_checks=True,连接会禁用心跳检查、退出省电并重置部分 CAN 配置,因此这一步也不是纯只读探测;异常被记录后仍进入恢复循环。

    每轮 count 偶数执行正常复位,奇数执行带 BOOT0 的 ROM 恢复。之后 PandaDFU.list() 合并 USB/SPI DFU 发现结果,包装器对所有 DFU 序列号顺序执行 recover();只有运行态 Panda.list() 最终恰好一块时才进入应用签名验收和原生进程。设备数大于一会抛 AssertionError,被宽泛异常分支记录后下一轮重试;无设备也直接下一轮。

    入口 实现语义
    reset_internal_panda() 初始化 reset 与 BOOT0;高电平保持 reset、BOOT0 低,10 ms 后释放 reset。
    recover_internal_panda() reset 高、BOOT0 高,10 ms 后释放 reset,再过 10 ms 拉低 BOOT0,使 MCU 进入 ROM 路径。
    gpio_init()、gpio_set() sysfs 写失败仅打印错误,不向上抛异常,也无强制读回。
    get_expected_signature()、flash_panda() 从 H7 应用镜像提预期签名,条件性刷应用固件、恢复 bootstub,最后比对运行态签名。
    pandad.main() 内部板 reset/recover 交替、恢复 DFU、限制运行态 panda 数量、启动并等待 C++ pandad。
    PandaDFU.list()、recover()、Panda.recover() USB/SPI DFU 枚举后逐个恢复;恢复属于有固件写入副作用的维护操作,等待 DFU 超时返回 False。

    复位极性:STM_RST_N 是高电平复位

    源码引脚注释特别说明 STM_RST_N 实际为高电平复位,不能按后缀 _N 推断极性。这一点直接决定了 reset_internal_panda() 与 recover_internal_panda() 的差异:前者保持 BOOT0 低,后者在释放 reset 后再拉低 BOOT0,从而把 MCU 送进 ROM 路径。

    更隐蔽的是 GPIO helper 会吞掉写异常并返回 None,因此包装器不会因为 reset/BOOT0 写失败而直接知道硬件没有进入预期模式。仅仅「写 sysfs 成功」不能证明 MCU 已启动。

    固件验收:bootstub 与签名门

    flash_panda() 在 bootstub 或运行签名不等于本地 H7 镜像签名时调用 flash()。若刷后仍在 bootstub,内部板先走主机 GPIO ROM 恢复,外部板则用 panda 自身 reset 进入 ROM,再调用 panda.recover();最后仍在 bootstub 或签名不匹配就抛错。Panda.recover() 的 False 返回值在此处没有单独检查,最终由 bootstub/签名门决定是否继续。

    清理层面也值得注意:panda.close() 只在正常完成的末尾显式调用;异常路径未见同一函数内的清理块。假依赖探针验证了控制流:运行签名匹配时跳过刷写并显式关闭;bootstub 内部板按 flash()、主机 GPIO 恢复、recover(reset=False) 的顺序调用,外部板则调用 recover(reset=True);recover() 返回 False 但假板卡最终状态和签名合格时仍可完成;最终签名不符时抛错且本函数未显式 close()。这些结果只说明包装器如何使用依赖,不说明真实恢复失败后仍可安全继续。

    无退避循环与 SIGINT 竞态

    签名验收通过后,包装器启动 ./pandad 并以 process.wait() 阻塞。原生子进程结束后回到外层恢复循环,没有固定退避;无设备、多个设备或持续协议错误同样可能快速反复切换内部板模式,具体速率取决于枚举和日志成本。

    包装器接收 SIGINT 时设置退出标记,并在子进程存在时转发 SIGINT;但退出标记只在下一轮循环入口检查。假依赖探针在运行态 Panda.list() 返回前注入 SIGINT,确认当前轮仍会调用 flash_panda() 并启动 ./pandad,且信号到达时子进程尚不存在,因此没有转发给新进程。这是需在真实进程/设备台架上处理和验收的退出竞态。

    失效点 静态表现 为何不能静默
    GPIO 写失败 helper 吞异常返回 None,无强制读回 包装器不知道 MCU 未进入预期模式。
    recover() 返回 False 调用点未检查布尔值 假 DFU 的返回不阻断后续流程。
    无设备/多设备 直接进入下一轮,无固定退避 可能快速反复切换内部板模式。
    SIGINT 退出标记只在下一轮入口检查 新子进程可能未收到转发信号。

    维护权限与插件边界

    主机平台插件可以提供板卡发现与复位请求,但 ROM 切换、固件写入和安全模式配置应由受信任维护服务独占,限制在驻车、供电稳定且目标设备身份已绑定的事务。主机 GPIO 应回报操作是否成功、复位线和 BOOT0 实测电平、设备重枚举代号以及截止时间。外置看门狗和 CAN 收发器默认无输出仍需在自制板上独立实现并验证。台架需分别注入 GPIO 拒写、BOOT0 卡高/卡低、无 panda/多 panda、USB/SPI DFU 同时出现、错误固件签名、刷写中断、原生子进程崩溃和 wrapper SIGINT;不能以包装器 PID 存活作为车辆控制授权。

    常见问题

    为什么 STM_RST_N 这个命名容易误导?

    因为源码引脚注释特别说明 STM_RST_N 实际为高电平复位,不能按后缀 _N 推断极性。复位极性直接决定 reset_internal_panda() 与带 BOOT0 的 recover_internal_panda() 行为差异。

    GPIO 写失败后包装器会知道吗?

    不会。GPIO helper 在 sysfs 写失败时仅打印错误、返回 None,不向上抛异常也无强制读回,因此包装器不会因为 reset/BOOT0 写失败而直接知道硬件没有进入预期模式。

    recover() 返回 False 会阻断流程吗?

    在当前调用点不会。Panda.recover() 的 False 返回值未被单独检查,最终由 bootstub/签名门决定是否继续;假依赖探针也确认返回 False 但假板卡最终状态和签名合格时仍可完成。

    为什么包装器存活不代表 CAN 就绪?

    manager 跟踪的是 Python 包装器的存活,而非独立证明固件签名、安全模式或 CAN 流已就绪。签名通过后包装器只是启动并等待 C++ pandad,因此不能以包装器 PID 存活作为车辆控制授权。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/PANDAD_HOST_RESET_DFU_RECOVERY_LOOP_DEEP_DIVE.md(取用了包装器时序、复位极性、固件验收、无退避循环与 SIGINT 竞态)。
    • 项目 02 —— openpilot-research/PANDA_BOOTSTUB_SIGNATURE_FLASH_BOUNDARY_DEEP_DIVE.md(仅用于确认固件签名与刷写门的边界)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • 实验模式三层状态:UI 图标、Params 请求值与运行值为何不一致

    结论:「实验模式是否生效」在源码里其实是三个不同的状态,很容易被一个 UI 图标混淆。一是请求值 ExperimentalMode,是用户意图,多个 UI 入口都能写;二是界面值,大屏行车按钮的新图标最多保持约两秒就回到消息报告的模式;三是运行值 selfdriveState.experimentalMode,由 selfdrived 在运行时车型能力门后发布,plannerd 在收到下一次模型更新时才使用它。源码没有提供端到端「点击已被规划器接受」的事务回执。以下为固定快照 c8fb906 的静态分析,不代表模型行为或实车交互已验证。

    请求入口:五个 UI 路径写同一个参数

    请求值可以从多处写入。大屏行车按钮的 ExpButton._update_state()/_handle_mouse_release() 从 selfdriveState.experimentalMode 取当前模式,在确认且 UI 认为有纵向控制时翻转 ExperimentalMode 参数;其 _engageable 只影响绘制透明度,不参与切换允许判断。大屏驻车主页的按钮点击进入设置,展示取自 Params,不直接切换模式。

    设置页的 TogglesLayout._handle_experimental_mode_toggle() 以 UI 能力门决定可用性,首次开启需确认,确认后阻塞写两个参数,不可用时清除请求参数。mici 首页的 HomeLayout._update_state() 在长按超过 0.5 秒且 UI 能力与确认均成立时,修改 UI 本地布尔值并阻塞写 ExperimentalMode;mici 设置页首次开启走确认页面,后续写请求参数,但这里的普通 put_bool 未指定 block=True。

    阶段 代码入口 源码可确认的行为
    大屏行车按钮 ExpButton._update_state()、_handle_mouse_release() 从 selfdriveState.experimentalMode 取当前模式;确认且 UI 认为有纵向控制时翻转参数。
    大屏按钮反馈 _held_or_actual_mode() 新图标保持约两秒后回到消息报告的模式;保持期只是视觉反馈。
    大屏驻车主页 HomeLayout._setup_callbacks()、ExperimentalModeButton.show_event() 点击进入设置;展示取自 Params,不直接切换模式。
    大屏设置 TogglesLayout._handle_experimental_mode_toggle() UI 能力门决定可用性;首次开启需确认;不可用时清除请求参数。
    mici 首页 HomeLayout._update_state() 长按超过 0.5 秒且能力与确认成立时,阻塞写 ExperimentalMode。
    mici 设置 TogglesLayout._on_experimental_mode() 首次开启走确认页;后续写请求参数,普通 put_bool 未指定 block=True。

    UI 能力计算集中在 UIState.update_params():它从 CarParamsPersistent 读车型能力,若 alphaLongitudinalAvailable 则展示门取 AlphaLongitudinalEnabled,否则取 CP.openpilotLongitudinalControl,后台约每 0.2 秒刷新。也就是说「按钮能不能点」和「模式实际会不会生效」用的是两套判定依据。

    参数持久化与两个布尔键

    ExperimentalMode 与 ExperimentalModeConfirmed 都是持久化布尔键。要点在于写盘语义:Params.put_bool(..., block=True) 会等待写盘,而不带 block=True 的调用只提交写入;这两种调用都不等于下游已消费。mici 首页调用的是通用 Params.put(..., block=True),其布尔值经类型转换后写入,与 put_bool 的 API 形式不同。

    键 / 调用 含义 边界
    ExperimentalMode 用户请求值 多入口可写;无纵向控制能力时可能被清除。
    ExperimentalModeConfirmed 首次确认记录 持久化布尔键,用于首次开启确认。
    put_bool(block=True) 等待写盘 写盘完成不等于下游已消费。
    put_bool() 仅提交写入 mici 设置的普通调用属此类。

    运行值由能力门决定

    运行时一侧,UIState.update_params() 从 CarParamsPersistent 读车型能力:若 alphaLongitudinalAvailable,展示门取 AlphaLongitudinalEnabled,否则取 CP.openpilotLongitudinalControl,后台约每 0.2 秒刷新。而 SelfdriveD.__init__() 在无 openpilot 纵向能力时启动清除请求;params_thread() 约每 0.1 秒计算 ExperimentalMode AND CP.openpilotLongitudinalControl,主循环再发布 selfdriveState.experimentalMode。

    这里有一个条件性差异值得注意:UI 的能力展示读 CarParamsPersistent 且在 alpha 可用时依赖 AlphaLongitudinalEnabled,实际模式却按运行时 CP.openpilotLongitudinalControl 判定。若持久车型参数、运行时参数或刷新时点不同,UI 允许/展示与实际激活可短暂不一致;静态阅读不能证明这种差异会在某辆车上发生。

    规划消费与规划激活

    plannerd.main() 与 LongitudinalPlanner.update() 随 modelV2 更新读取 selfdriveState;实验模式改变巡航加速度计算,并把模型 desiredAcceleration 加入候选,最终按候选的最小加速度选目标——不是「模型始终直接控制油门刹车」。下游 controlsd 从 longitudinalPlan.aTarget 经纵向控制器生成候选执行器输出,card 还读取参数作为启用时巡航初值的输入,二者各有各自的更新时序。

    面向自制硬件与插件重构

    把 UI 插件的操作定义为 ModeChangeRequest,携带请求 ID、来源、单调时间戳和车辆能力版本;只授权写请求,不授予其直接发布 selfdriveState、longitudinalPlan 或 CAN 的能力。由固定核心层发布 requested / rejected / accepted / active 等结构化状态,其中 active 必须来自规划器消费确认,不能用图标、Params 写盘或 selfdriveState 单独代替;当前源码尚无此回执协议。

    能力门应以运行时 CarParams 与独立网关安全策略为权威,UI 可显示能力但其缓存不得成为执行许可。验证顺序上,先用回放验证参数切换、状态发布、规划候选选择及失联时序,再对目标车型验证 controlsd 活动门、card 输出与 panda 安全钩子。

    常见问题

    点了实验模式按钮,规划器就立刻用上了吗?

    不一定。按钮翻转的是请求值 ExperimentalMode,运行值 selfdriveState.experimentalMode 由 selfdrived 在能力门后发布,plannerd 要等下一次模型更新才使用;源码没有端到端「点击已被规划器接受」的事务回执。

    大屏上的新图标表示模式已切换成功吗?

    不能这样理解。_held_or_actual_mode() 里的新图标最多保持约两秒,然后回到消息报告的模式;这段保持期只是视觉反馈,不是参数写盘或规划激活的回执。

    put_bool(block=True) 写盘成功就说明下游已消费吗?

    不等于。block=True 只是等待写盘完成,不带 block=True 的调用只提交写入;两种调用都不代表下游已消费,mici 设置页用的就是不带 block 的普通 put_bool。

    为什么 UI 显示的可用性与实际激活会不一致?

    因为判定依据不同:UI 能力展示读 CarParamsPersistent、在 alpha 可用时依赖 AlphaLongitudinalEnabled,实际模式按运行时 CP.openpilotLongitudinalControl 判定。持久参数、运行时参数或刷新时点不同就可能短暂不一致,但不能据此断定会在某辆车上发生。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/EXPERIMENTAL_MODE_UI_PARAM_ACTIVATION_DEEP_DIVE.md(取用了五个 UI 请求入口、两个持久化布尔键、三层状态划分、运行值能力门与规划消费说明)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • 常规 uploader:候选选择、HTTP 结果与退避循环

    结论:常规 uploader(system/loggerd/uploader.py)每轮只上传一个对象,且它的「成功」是本地终态,不是远端交付回执。源码用同一个 upload_success 事件与 user.upload=1 标记覆盖 200/201 与 401/403,把「零字节/超限文件跳过」也一并记为成功;因此不能据事件或 xattr 推断服务端已存完整对象。它也不是 Athena 的指定 URL 队列,两套逻辑要分开看。

    函数级调用链

    基线是本地 source/ 快照 c8fb906,结合静态阅读及四项原 upload() 假依赖探针;没有发网络请求、清锁、写真实扩展属性或运行设备测试。

    函数 具体行为
    get_directory_sort() / listdir_by_creation() 路段名按末个 -- 拆成两段、右对齐并在左侧补零后排序;这是目录名排序,不是读取文件创建时间。只选日志根下一层目录。
    clear_locks() 进程启动时遍历根目录各子项,删除名称以 .lock 结尾的文件;未逐项确认对应写入者已退出。
    list_upload_files() 跳过含任意 .lock 文件的目录及 user.upload=1 文件;计费网络下限制 qcamera.ts 的路段前缀。枚举不等于会被下游选中。
    next_file_to_upload() 先物化全部候选到列表,第一轮按路径包含 crash/ 或 boot/ 选;第二轮只选 qlog、qlog.zst、qcamera.ts。没有候选则返回 None。
    do_upload() / upload() 用设备令牌获取上传 URL/headers;按 key 与原文件扩展名决定是否压缩,然后 requests.put(..., timeout=10);压缩源完整进入内存缓冲。

    排序与清锁两处都带假设。目录排序只是把路段名按末个 -- 拆开、右对齐补零后比较字符串,并非读取文件创建时间;清锁则在启动时直接删除所有以 .lock 结尾的文件,没有逐项确认对应写入者已退出。两者都只保证「本地看起来就绪」,不等于段已经写完或写入者已经停止。

    选择与标记的精确语义

    list_upload_files() 的产出是候选流,next_file_to_upload() 才决定本轮只上传一个对象。其第一优先级按完整路径中的 crash/、boot/ 子串匹配,第二优先级仅包含 immediate_priority 的三个文件名;普通路段 rlog、前后/座舱原始视频即使在候选流中,也不会被常规循环直接选中。还需注意 MAX_UPLOAD_SIZES 只含 qlog 和 qcam 文件名,不能把它当作对 qlog.zst 或 qcamera.ts 的通用大小上限。

    传输实现还有一处容易被误解的地方:get_upload_stream() 对未压缩文件返回文件句柄,对压缩文件则先完整压缩到 BytesIO 再上传,所以「边上传边压缩」并不是本路径的实现;do_upload() 还忽略返回的压缩后长度,本层没有独立校验上传字节数与对象摘要。FAKEUPLOAD 会令非 412 的 URL 响应直接返回假成功,只适用于隔离测试,不是生产交付回执。

    结果分支 源码处理 能/不能推断什么
    零字节 / 命中上限且超限 不调用上传 API,但仍置 success=True 并写 user.upload=1。 只说明本地跳过,不说明远端收到。
    412 URL API 或 PUT 的 412 记为 upload_ignored。 表示被忽略,不等于失败或成功。
    200/201 与 401/403 都走名为 upload_success 的事件和本地标记。 不能据该事件或 xattr 推断服务端已存完整对象。
    setxattr() 失败 通常只记事件,函数仍返回 True。 文件可能下轮被再次选择。

    还有一个条件性错误路径:last_exc 只在尝试 do_upload() 的分支初始化;零字节或超限文件直接进入成功标记分支。如果此时 setxattr() 抛 OSError,其异常处理日志引用未定义的 last_exc,会引发 UnboundLocalError 并退出当前调用,外层 main() 没有包住 step()。假 xattr 探针分别复现了零字节与超限文件的原函数行为,但并未执行真实文件系统故障。另外,do_upload() 的 debug 日志还会记录预签名 URL 和 headers,这些值可能含短期访问凭据,生产日志策略需单独审计。

    网络调度与测试边界

    main() 从 deviceState 读 networkType/networkMetered,SubMaster.update(0) 后没有在这里核验消息新鲜度或外层有效位。FORCEWIFI 仅把用于「是否无网」的局部 network_type 设成 Wi-Fi,传给 step() 的仍是原始 deviceState 类型及计费位。

    情形 睡眠/退避
    无网 睡 60 秒(驻车)或 5 秒(行车)。
    无候选 以同样基数加随机抖动等待。
    成功后 基数 0.1 秒。
    失败时 基数先翻倍、最大 120 秒,再睡基数到两倍之间。

    UPLOADER_SLEEP=0 会跳过睡眠,适合测试而非目标硬件常态。test_uploader.py 用 mock URL API 与 FAKEUPLOAD 覆盖正常/412、目录顺序、锁文件和 xattr;它没有验证真实 HTTP PUT、401/403、预签名 URL 解析失败、零字节且 xattr 失败、计费网络状态切换或掉电恢复。测试中的 clear_locks 确认启动时会删除锁,但不证明活动 writer 不受影响。本轮未运行原仓库测试;四项隔离探针只覆盖 upload() 局部控制流。

    自制硬件的上传插件契约

    自制硬件的上传插件应把「未尝试/待传/传输中/被服务端接受/拒绝/本地跳过/确认存储」分别持久化,并以文件摘要或对象版本核对交付结果。上传器与 loggerd 应协商段关闭与锁所有权,不能在不确定写入者状态时全局清锁;对网络费用、RAM 中整文件压缩、带宽、eMMC 写放大和重试设预算。台架验收需要覆盖以上结果分支、网络状态失联、API/PUT 各类 HTTP 码、断电与 xattr 不可用,再验证实板资源峰值。

    常见问题

    uploader 报了 upload_success,文件一定上传成功了吗?

    不一定。源码让 200/201 与 401/403 都走名为 upload_success 的事件和本地标记,零字节或超限文件也会置 success=True 并写 user.upload=1 但根本不调用上传 API。这些都是本地终态,不能据事件或 xattr 推断服务端已存完整对象。

    为什么普通路段的 rlog 和视频不会被上传?

    因为 next_file_to_upload() 第一轮只按路径包含 crash/ 或 boot/ 选,第二轮只选 qlog、qlog.zst、qcamera.ts 这三个 immediate_priority 文件名。普通路段的 rlog 与前后/座舱原始视频即使在候选流里,也不会被常规循环直接选中。

    FORCEWIFI 会改变计费网络判断吗?

    不会完全改变。源码中 FORCEWIFI 仅把用于「是否无网」的局部 network_type 设成 Wi-Fi,而传给 step() 的仍是原始 deviceState 类型及计费位。因此计费相关的限制仍按原始状态生效。

    清锁会不会影响正在写入的日志?

    clear_locks() 在进程启动时遍历根目录各子项,删除名称以 .lock 结尾的文件,但源码未逐项确认对应写入者已退出;测试里的 clear_locks 也只确认启动时会删除锁,不证明活动 writer 不受影响。因此自制硬件应让上传器与 loggerd 协商锁所有权,而不是在不确定写入者状态时全局清锁。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/UPLOADER_SELECTION_RESULT_BACKOFF_DEEP_DIVE.md(取用了候选选择与标记语义、结果分支、退避参数、条件性未定义变量路径与上传插件契约)。
    • 项目 02 —— openpilot-research/ATHENA_RECENT_ROUTES_METERED_UPLOAD_CONTRACT_DEEP_DIVE.md(仅用于确认计费网络最近路段门的接续关系,未取用其未公开内容)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • URLFile 缓存完整性:查询串不入键与接受 200 全量响应

    结论:hash_url() 只对去掉 ? 之后的 URL 计算 MD5 作为缓存键,因此签名查询串变化仍共用同一缓存,可能读到另一版本甚至另一授权上下文的字节。read() 默认把请求范围扩到 1,000,000 字节对齐块,但即使某块实际响应短于要求,也不核对块大小、Content-Range 或最终输出长度,返回后位置仍设为目标终点;get_multi_range() 接受 HTTP 200/206 且不核对单段偏移。以下为固定快照 c8fb906 的静态阅读与隔离探针,未访问远端、未运行会删除本机缓存的上游测试、未读取真实路段。

    函数链

    url_file.py 是离线日志/视频的远端字节入口,其文件接口与常规文件类似,但每一层都缺少内容校验。下表列出关键函数。

    请求层与文件层的语义也要分开看:pool_manager()/_request() 复用 urllib3 池并配置特定重试与超时,但超时是请求级配置,不提供整个多块读取操作的总 deadline;seek() 只更新内部位置,SEEK_END 依赖已知长度,既不限制负偏移也不限制越过 EOF,异常要等后续读取才暴露。

    函数 实现 数据契约与风险
    hash_url() 去掉 ? 后的 URL 计算 MD5,作为块与长度缓存键 同一路径但查询参数不同(如带签名 URL)会共享缓存;MD5 这里只是文件名,不是内容完整性校验
    pool_manager() / _request() 复用 urllib3 池,配置特定 409/429/503/504 重试及 connect/read 超时;fork 后重置池引用 超时是请求级配置,不提供整个多块读取操作的总 deadline
    get_length_online() / get_length() HEAD 的 2xx Content-Length 转整数;缓存长度单独落盘 无 Content-Length 按 0 处理,非 2xx 返回 -1;本地长度缓存不随远端 ETag/内容版本自动失效
    read() 默认把范围扩到 1,000,000 字节对齐块;不存在时从远端读并原子写入缓存,命中时直接读文件,再裁切请求片段 即使某块实际响应短于要求,也未核对块大小、Content-Range 或最终输出长度;请求零长度也会进入块处理循环
    read_aux() 以当前位置构造单段 Range,调用 get_multi_range(),按返回字节数推进位置 仅依赖收到的字节,不校验其与请求区间的对应关系
    get_multi_range() 构造含端点的 Range 头;接受 HTTP 200/206;非 multipart 直接返回单个字节串,multipart 按 boundary 切分 不核对单段 Content-Range、起止偏移或长度;multipart 用 .rstrip(b"rn") 可能删掉合法尾部字节
    prune_cache() manifest.txt 存每个新块的写入时刻,按条目数乘块大小估计容量,再删最旧条目 缓存命中不刷新时间,实际是近似创建时间淘汰,不是访问级 LRU;真实块大小与并发写入未纳入容量/事务保证
    seek() 更新内部位置;SEEK_END 依赖已知长度 不限制负偏移或越过 EOF,后续读取才可能暴露异常

    Range 响应隔离验证

    四项原方法探针从归档提取 get_multi_range()/read_aux(),注入固定 HTTP 响应,不加载 urllib3 或写入缓存。

    假响应与请求 实际结果 合约缺口
    请求 bytes=5-7,返回 200 和完整 10 字节文件 原样返回 10 字节;read_aux(ll=3) 从位置 5 前进到 15 200 全文件没有按 Range 裁切,也不校验 Content-Range 或请求长度
    请求 5 字节,multipart 单段载荷为 abcrn 结果为 abc .rstrip(b"rn") 把合法载荷尾部也删掉
    请求两个 Range,206 非 multipart 只返回 AB 返回 [b"AB"] 非 multipart 分支没有检查响应段数与请求数
    请求一个 Range,返回 404 抛 URLFileException 状态拒绝正常,但不证明其他状态的字节正确

    这些输入在原函数分支下可复现,但不是对真实存储服务会如此响应的断言。生产修复需按请求逐段核对状态、Content-Range、实际长度和顺序,不能只依赖状态码或 multipart 段数。

    缓存读取隔离验证

    三项原方法探针用临时目录运行 read()、read_aux()、hash_url() 和原子写;为降低开销,只把全局 CHUNK_SIZE 从生产的 1,000,000 改为 4 字节,远端响应仍由内存假方法提供。

    受控输入 实际结果 边界
    请求 4 字节,假来源仅给 ab 返回 2 字节,缓存也保存 ab,位置却推进到请求终点 4 缓存存在性代替内容长度/范围校验,短块可成为持续错误输入
    read(ll=0) 返回空字节,但仍取一次 4 字节块并写缓存 零长度请求没有在 I/O 前早退
    相同 URL 路径,仅签名查询串变化 新请求读到前一查询串的 old! 缓存,没有调用假远端 缓存键不含查询串或远端内容版本

    这些结果验证原函数在缩尺块长下的控制流,不能证明真实 1 MB 块的性能、并发缓存写入、远端授权差异或实际模型回放输出。

    对模型与硬件回归的影响

    FileReader() 将 HTTP(S) 交给 URLFile;LogReader 与视频 FfmpegDecoder.get_iterator() 因此可能消费这些缓存块。若服务端忽略 Range 返回 200 全文件,非零偏移读可取得错误片段;若旧缓存与新 URL 查询串共享键,回放可能混合版本。因此 HEAD 成功、缓存命中、Cap’n Proto 可解析或 FFmpeg 能输出帧,都不能单独证明输入字节与固定参考数据一致。

    现有 test_caching.py 检查默认池配置、正常缓存与非缓存读的一致性、缺失文件恢复和淘汰;其中部分测试访问公网,还有测试会删除下载缓存根目录,故本次没有运行该上游套件。隔离探针现已覆盖忽略 Range 的 200 响应、multipart 尾部 CRLF、短缓存块、零长度读取和查询串共用键;并发清单覆盖、真实云端版本和内容哈希仍未动态验证。

    分块缓存的键、长度与时序都缺少内容身份:hash_url() 用 URL 路径的 MD5 作键,长度缓存单独落盘且不随远端 ETag/内容版本失效,prune_cache() 的容量估计只按条目数乘块大小,不含真实块大小、_length 文件与并发写入。因此「缓存命中」只说明本地存在文件,不说明该文件与当前远端内容一致。

    面向插件化重构,远端数据源应携带固定内容摘要/大小、ETag 或版本 ID,并将身份和版本纳入缓存键;每次 Range 响应检查状态、Content-Range、长度及顺序,完整文件下载后校验 SHA-256。缓存清单应可并发更新且按实际字节计量,明确过期/撤销与隔离域。自制硬件的模型回归必须保存完整输入清单,而不是仅保存可变 URL。

    常见问题

    带签名的 URL 会共用缓存吗?

    会。hash_url() 只对去掉 ? 之后的 URL 取 MD5,查询串不入键;若响应内容由查询参数决定,可能读到另一版本甚至另一授权上下文的字节。这里的 MD5 只是文件名,不是内容完整性校验。

    缓存命中的块长度可信吗?

    不可信。缩尺探针里请求 4 字节、假来源只给 2 字节时,read() 返回 2 字节、缓存也保存 ab,但位置推进到请求终点 4;再次读取会直接复用这个短块。

    服务端忽略 Range、返回 200 全文件会怎样?

    请求 bytes=5-7 却拿到完整 10 字节时,原函数原样返回 10 字节,read_aux(ll=3) 从位置 5 前进到 15,既不按 Range 裁切也不校验 Content-Range。

    怎样防止回放混合版本?

    让远端数据源携带固定内容摘要/大小、ETag 或版本 ID,并把身份与版本纳入缓存键;每次 Range 响应检查状态、Content-Range、长度及顺序,完整下载后校验 SHA-256。模型回归应保存完整输入清单,而不是仅保存可变 URL。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/URLFILE_RANGE_CACHE_INTEGRITY_DEEP_DIVE.md(HTTP Range、分块缓存与回放完整性)
    • 项目 02 —— openpilot-research/LOGREADER_DECODE_ITERATION_LIFECYCLE_DEEP_DIVE.md(消费缓存块的日志解码器,作为本文的补充阅读)

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • 设备 CI 的四道门:锁、凭据、检出与测试跳过判定

    结论:设备 CI 的保护不是一道门,而是四道:设备锁、凭据注入、检出隔离与测试跳过判定。真正的风险不在「CI 通过」,而在两处静态缺口——差异列表用子串匹配判断是否跳过步骤,且检出快路径会保留被忽略文件与旧构建产物。UNSAFE=1 只是检出快路径的环境变量,不是车辆 safety 许可。

    阶段骨架:node 到 deviceStage

    流水线从 node 开始:setupCredentials() 注入三类 token,checkout(scm) 取分支/提交,再按分支选择 staging/nightly 或常规矩阵。特殊 chestnut-stresstest 最多循环 100 次并归档报告;其余按分支触发 staging/nightly,未排除分支运行常规并行矩阵。整个流水线的控制权都集中在 Jenkinsfile 的 node 段,因此插件化改造的关键不是换脚本,而是把设备锁、凭据与步骤过滤各自契约化。

    函数/阶段 主要实现契约
    setupCredentials() 从 Jenkins 凭据读三类 token 并放入 env;后续 device() 把它们扩展到远端 shell 环境。SSH 私钥另在 withCredentials 中取得。
    device() 用 alpine-ssh 包装的远端 SSH;连接禁用严格主机密钥检查。AGNOS 上清临时目录/共享内存、必要时启动 DNS 与重绑 USB hub;链接 Python 路径,执行步骤并等待子进程退出。
    deviceStage() 禁止 Jenkins Replay;按设备标签 lock(quantity: 1) 独占,在 Docker 中给整个阶段 35 分钟;设备时间同步与检出一起最多重试三次,测试步骤本身不自动重试。
    hasPathChanged() 对 GitHub compare 的返回字符串做子串匹配,不是解析已验证的文件列表;非 master/非 Jenkins loop 分支可据 diffPaths 跳过部分 build/test。
    chestnutStage() build、Chestnut 模型回放和指定 TestChestnutOnroad;设备标签为 mici-chestnut-ci。

    devel-staging 调用设备上的 build_release.sh 推 tizi/mici staging;__nightly 同时提交普通与 PANDA_DEBUG_BUILD=1 变体;__nightly-chestnut 加入 INCLUDE_BIG_MODEL=1。普通矩阵并行覆盖 onroad、功耗/编码器/manager、两种 camerad、sensord、模型回放、panda loopback/SPI/功放及 Chestnut。

    凭据与 SSH 信任边界

    setupCredentials() 从 Jenkins 凭据读 Azure、CI 工件、GitHub 评论 token 并放入 env,随后 device() 把它们扩展到远端 shell 环境;SSH 私钥另在 withCredentials 中取得。远端连接禁用严格主机密钥检查,因此 CI 网络、节点与凭据范围应被视为独立信任边界。

    需要分清的是:lock(label=...) 是 Jenkins 设备池的并发协调,不是车端进程互斥。在自建硬件平台上应收紧主机验证并落实凭据最小权限,而不是把 CI 的协调机制误当作运行时的排他保证。

    从信任边界看,凭据实际有三层:Jenkins 环境中的凭据存储、经 device() 扩展到远端 shell 的环境变量,以及独立取得的 SSH 私钥。三层的作用域应各自最小化,远端主机验证应收紧;本地 ZIP 归档无法代表真实 Jenkins 插件及凭据配置,因此本文的结论只到脚本与流水线文本层。

    检出两条路径:unsafe 与 safe

    setup_device_ci.sh 先要求 SOURCE_DIR、GIT_COMMIT、TEST_DIR,清数据与 staging 目录,再写 /data/continue.sh。该脚本中的 continue.sh 内容含 abctl --set_success、sshd/NetworkManager 修改与长循环;此处只写文件并设可执行位,后续由什么启动机制执行应另行验证,不能把写入等同于本轮已执行。

    路径 实现 结果/代价
    快路径 unsafe_checkout() 直接在 TEST_DIR fetch/强制 checkout/reset,清理,对子模块做 reset/clean/update。 不使用忽略文件清理标志,忽略文件和旧构建产物可留存;失败后回退 safe。
    全清路径 safe_checkout() 在 SOURCE_DIR fetch 固定提交,清顶层内容、全量清理与子模块,拉 LFS,再 rsync -a --delete 到 TEST_DIR。 删未跟踪/忽略文件和目标旧内容,隔离更强但仍依赖 Git 远端、LFS 与 rsync 成功。
    LFS pull_lfs() CHESTNUT 时拉全部;普通设备排除 big driving 模型,必要时恢复指针并删对应本地 LFS 对象。 防止普通设备携带大模型实体;只代表该脚本处理的路径,不证明所有模型/构建输入完整。
    包导入路径 在 TEST_DIR 创建 msgq/opendbc/rednose/teleoprtc/tinygrad 包链接,与运行时 Python 导入结构对齐。 链接结构需与运行时一致,否则导入行为分叉。

    safe_checkout() 在脚本中会删除工作目录内容、源仓锁文件与设备真实路段缓存;这只能在专用 CI 设备上使用,不能作为用户车辆的插件更新机制。

    跳过与漏测风险:hasPathChanged 的子串匹配

    deviceStage() 用 curl -s ... | jq ... || echo "/" 取得差异;未使用失败即错选项、管道错误传播或结构化校验。API/解析失败可能返回仅 /,后续 hasPathChanged() 找不到目标路径,导致带 diffPaths 的步骤被跳过。这是代码路径风险,不表示当前 CI 已发生漏测。

    跳过的范围也需准确理解:diff 是与 master 比较后的文本,跳过仅应用于带 diffPaths 的步骤且非 master/非 Jenkins loop,不是整个矩阵的开关。模型回放路径的 build 和 replay 都带同一过滤条件,其他硬件测试大多总跑。

    CI 绿灯不等于实车授权

    Chestnut 压测仅对特定中断异常记 FAIL 并继续,其它异常会使循环中断,但 finally 仍归档包含 INCOMPLETE 的报告;常规分支的测试集合还受设备资源、差异跳过和环境变量影响。因此 CI 绿灯不能替代完整覆盖,也不能作为实车安全性的证明。本专题未运行 Jenkins Groovy、compare API、SSH、设备锁、模型回放或硬件测试。

    换句话说,四道门保护的是「谁来跑、在哪跑、跑什么、跑哪些」这四件事,它们各自都有可被绕过的边界;把它们理解为一条不可绕过的单一闸门,正是设备 CI 最常见的误读。

    常见问题

    为什么差异接口失败可能导致步骤被跳过?

    因为 deviceStage() 用 curl -s ... | jq ... || echo "/" 取得差异,未使用失败即错、管道错误传播或结构化校验。API/解析失败可能返回仅 /,hasPathChanged() 找不到目标路径,带 diffPaths 的步骤就会被跳过。

    省略的检出快路径有什么代价?

    unsafe_checkout() 不使用忽略文件清理标志,忽略文件和旧构建产物可留存,因此隔离更弱;失败后才回退到 safe_checkout()。这条快路径仅适用于专用 CI 设备。

    lock 是否等同于车端进程互斥?

    不等同。lock(label=...) 是 Jenkins 设备池的并发协调,不是车端进程互斥。CI 网络、节点与凭据范围应被视为独立信任边界,并在自建硬件平台收紧主机验证与凭据最小权限。

    CI 全绿是否代表实车安全可用?

    不代表。Chestnut 压测仅对特定中断异常记 FAIL 并继续,其它异常会中断循环但仍归档 INCOMPLETE 报告;常规测试集合还受设备资源、差异跳过和环境变量影响,CI 绿灯不能替代完整覆盖。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/JENKINS_DEVICE_CI_CHECKOUT_LOCK_CREDENTIAL_TEST_GATE_DEEP_DIVE.md(取用了阶段骨架、凭据与 SSH 边界、检出两路径及跳过与信任边界)。
    • 项目 02 —— openpilot-research/RELEASE_BUILD_FILE_SELECTION_LFS_PUSH_CONTRACT_DEEP_DIVE.md(仅用于确认发布构建与强推语义的边界)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • loggerd 测试:合成 VisionIPC、qlog 抽样与隐私门

    结论:loggerd 的测试用合成输入覆盖记录器主链,而不是真实相机链路:它自建名为 camerad 的 VisionIPC server,三路各 40 个 320×240 NV12 缓冲,以 20 Hz 等价循环发送,并随机发布 cereal 服务消息。这决定了它的证据边界——它能校验 rlog/qlog 数量与顺序、段文件集合与书签保留,却不能证明真实相机时序、AV 同步、失帧恢复或编码器 buffer 所有权。另外两处易被高估的断言是:test_rlog 没有在循环后断言发送列表已空,test_record_audio 的 ffprobe 未检查退出码。

    输入夹具:合成 VisionIPC 与随机 cereal 服务

    CEREAL_SERVICES 取 Event schema 中同时位于服务表、允许记录且名称不含 encode 的主题。_publish_random_messages() 启动 loggerd,对每个服务等读者;每轮给所有服务发新 Event,随机发 200~1000 轮,每 100 轮及结束时等读者更新,随后停止进程。源文档提醒,这个等待反映消息队列读者进度,不等同于磁盘同步完成。

    _publish_camera_and_audio_messages() 自建 VisionIPC server,三路各 40 个 320×240 NV12 缓冲;设置 LOGGERD_TEST 与段长,启动 loggerd、encoderd 后以 20 Hz 等价循环编号发送三路缓冲/CameraState、每轮 800 个 int16 零音频样本。它不启动真实 camerad,不测传感器寄存器、曝光、DMA 或时钟同步。源文档也说明该测试文件既有可在 PC 上运行的逻辑,也会调用本机编译产物、FFmpeg、VisionIPC 与日志文件。

    源文档特别指出:视频载荷来自 np.empty,未显式初始化为有意义像素;VisionIpcServer.send() 的时间戳参数是 uint64_t,夹具传入 n/fps 浮点秒值,因此不能把测试输出当真实纳秒级相机时间契约。

    夹具/检查 实际内容
    _get_latest_log_dir() 按目录 mtime 取最新日志段;依赖当前测试 Prefix/LOG_ROOT 下的目录列表。
    _gen_bootlog() 调本地 ./bootlog 二进制并在 5 秒 Timeout 内从输出解析文件路径;要求构建产物。
    _check_init_data() 只检查第一条消息类型为 initData;没有检查所有字段。
    _check_sentinel() 用第二条及末条消息核对 route/segment 起止标记。

    各测试的证据范围

    测试 断言与限制
    test_init_data_values() 写入构建身份等 Params,检查 initData 映射、版本、内核字段;AccessToken 虽在参数列表中但记录值必须为空;只覆盖该组参数,不证明其他敏感字段已净化。
    test_bootlog() 写 /tmp/launch_log,要求 bootlog 含两条消息、wall time 约 5 秒内、launchLog 相等;两次 bootlog 的 UID 不同且 BootCount 从 0 到 1;写入的 /tmp/launch_log 未在测试中删除。
    test_qlog() 从有/无 decimation 的服务各随机挑子集,只核对 qlog 中每主题的数量是否符合公式;不核对选中消息具体载荷或跨段抽样相位。
    test_rlog() 随机 5~10 种服务,逐条从相应服务的发送列表头部弹出并比较序列化字节;没有在循环后断言各发送列表已空。
    test_rotation() 随机 2~3 段、4~5 秒/段,要求每段文件名集合恰为 rlog、qlog、qcamera、三路 HEVC;不读取这六个文件的码流或每段内容。
    书签保留两组用例 对有/无 userBookmark 的段核对 xattr;不验证跨进程 xattr 负缓存、上传/删除器的后续保留决策。
    test_record_front() 真/假两种启动前 Params 值,仅比较最新段 dcamera.hevc 是否存在;不核对动态切换、内容、索引或隐私擦除。
    test_record_audio() 真/假两种启动前 Params 值,检查 qcamera 是否有音轨及 rlog 是否出现原始音频;ffprobe 子进程未检查退出码。

    test_qlog/test_rlog 使用随机服务及消息数量且未固定 seed,所以一次通过不能覆盖所有主题/抽样组合。源文档也澄清:test_rlog 的逐条比较确实检查了已读取消息的载荷,不应把缺少尾部断言误写成完全没有内容校验。

    test_rotation() 只要求每段文件名集合恰为六个文件,不读取码流内容,因此它验证的是段切分与命名,而不是每段编码的正确性。

    隐私与录音门的边界

    test_init_data_values() 只覆盖该组参数,源文档明确不证明其他敏感字段已净化。test_record_front() 只比较最新段 dcamera.hevc 是否存在。test_record_audio() 存在一个易漏点:subprocess.run(ffprobe..., capture_output=True) 未检查退出码,命令失败而 stdout 为空时,关闭录音用例的「无音轨」条件仍可能通过。源文档据此要求在插件化时区分「明确无音轨」和「探针失败」。

    书签保留的两组用例只核对 xattr,源文档指出它们不验证跨进程 xattr 负缓存、上传/删除器的后续保留决策;test_bootlog() 写入的 /tmp/launch_log 也未在测试中删除。这些残留与边界提醒说明,测试覆盖的是「可见输入 + 源码断言」,不是端到端的保留与清理保证。

    插件化验证要求

    将记录功能拆成插件时,应保留独立契约:服务注册/抽样、rlog 顺序和完整性、媒体包/索引对应、音频/座舱录制许可、书签后的保留与上传、异常关机后的文件可读性。对每个测试输入记录种子,断言发送与接收双向数量相等;FFprobe 等外部探针须检查退出码。

    真实相机插件还需带纳秒时间戳的帧级对齐、NV12 形状/stride/有效位、坏帧与断流注入。源文档最后限定:全部结论限定于源码断言与可见输入,未运行构建、FFmpeg、进程或媒体生成,因此无法确认各测试在本归档环境的实际通过情况,本 suite 也不能替代实机时序和隐私审计。

    常见问题

    loggerd 测试用的是真实相机吗?

    不是。源文档明确夹具自建名为 camerad 的 VisionIPC server,三路各 40 个 320×240 NV12 缓冲,不启动真实 camerad,也不测传感器寄存器、曝光、DMA 或时钟同步。

    为什么测试里的相机时间戳不能当真实契约?

    VisionIpcServer.send() 的时间戳参数是 uint64_t,而夹具传入 n/fps 浮点秒值,源文档明确不能把测试输出当真实纳秒级相机时间契约,具体转换效果需用构建后的绑定验证。

    test_rlog 通过是否说明日志完整?

    不足以保证完整。它逐条比较从各服务发送列表头部弹出的序列化字节,但没有在循环后断言发送列表已空,因此若日志只保留每主题的正确前缀,尾部缺失不会由这条断言本身发现。

    test_record_audio 判定「无音轨」可信吗?

    需要警惕。源文档指出 subprocess.run(ffprobe..., capture_output=True) 未检查退出码,命令失败而 stdout 为空时,关闭录音用例的「无音轨」条件仍可能通过,插件化时应区分「明确无音轨」和「探针失败」。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/LOGGERD_TEST_SYNTHETIC_VIPC_QLOG_RLOG_PRIVACY_GATES_DEEP_DIVE.md(取用了合成 VisionIPC 与随机 cereal 夹具、各测试证据范围表、隐私与录音门边界、插件化验证要求)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • Athena 主连接:70 秒 Ping 门不是定时器,跨会话队列不清空

    结论:Athena 主 WebSocket 长连接的存活判断里有两处「看起来在保护、实际没有」。第一,70 秒 Ping 门不是独立定时器:只有当 recv_data() 抛 WebSocketTimeoutException 时才检查距上次 Ping 的年龄,业务帧持续到来时不会触发检查。第二,recv_queue、send_queue、log_recv_queue、upload_queue 都是模块级对象,新会话重建线程却不重建队列,旧请求或回复可能在断线后被新会话处理。此外 send_queue_push() 的队列未设 maxsize,4096 的单位是 Python 字符。以下为固定快照 c8fb906 的静态追踪,未连接生产服务或运行车载设备。

    关键组件与调用链

    设备到 Athena 的主连接由几个线程协作:一个接收线程、一个发送线程、一个 keepalive 线程、四个上传 worker、一个诊断日志 worker 以及 HANDLER_THREADS 个 RPC worker,全部由单个 end_event 管理。manage_athenad.main() 在子进程结束后等 5 秒再启动,这一层只观察进程退出,不验证 WebSocket 认证或 RPC 健康。

    组件 行为与限制
    manage_athenad.main() 子进程结束后等 5 秒再启动;不验证认证或 RPC 健康。
    athenad.main() 读 DongleId 与上传队列;用 Api.get_token() 连 /ws/v2/{dongle_id},create_connection(timeout=30.0),成功后清当前上传字典。
    handle_long_poll() 单个 end_event 管理收发/keepalive/四个上传/一个日志/RPC worker;exit_event 每 0.1 秒检查,逐个 join(),无统一 join 截止期。
    ws_recv() 文本解码 UTF-8、二进制入 recv_queue;收到 Ping 写本机单调时钟纳秒到 LastAthenaPingTime。
    ws_send() 从进程级优先队列取一条消息,按最多 4096 字符发首帧与 continuation;发送前已出队,异常只设结束事件不回填。
    ws_manage() 依 IsOffroad 每 5 秒轮询切换 TCP keepalive/USER_TIMEOUT;无本地异常处理。
    backoff() 返回 [0, min(128, 2**retries)) 的随机整数秒。

    70 秒门不是定时器

    Ping 写入的是 time.monotonic() 纳秒,不是 Unix 时间。ws_recv() 里的 70 秒门只在 recv_data() 抛 WebSocketTimeoutException 时检查:当底层持续返回业务帧却没有 Ping 时,循环不进入超时分支,源码不会检查 Ping 年龄;只有当底层持续超时、超过门限的那次超时才触发结束。这是控制流推导,实际帧与超时节奏取决于 WebSocket 与网络。

    LastAthenaPingTime 只在收到 Ping 时更新,并在 main() 捕获连接异常或 manager 启动清理时删除;而收发 worker 设置 end_event 后正常退场的路径不删除它。因此某段时间内该 Params 可以保留旧 Ping 值,消费者必须用同一单调时钟域计算年龄,并区分「历史最后 Ping」与「当前会话活着」。hardwared.py 只是把它复制到 deviceState.lastAthenaPingTime,没有按年龄推导实时连接健康。

    取值 含义 边界
    70 秒 Ping 年龄门 仅在 recv 超时分支检查,不是独立定时器。
    4096 字符 ws_send() 分帧长度 单位是 Python 字符,协议帧字节长度并非固定 4096。
    5 秒 守护层重启间隔;ws_manage() 轮询间隔 仅观察进程退出,不代表 RPC 健康。
    0.1 秒 exit_event 检查间隔 退出依赖各 worker 及时响应,无统一截止期。
    128 backoff() 上限 首次异常可能等 0 或 1 秒。

    状态转移与跨会话队列

    连接成功后 main() 把 conn_retries 置 0 再调用 handle_long_poll()。收发线程异常会设置 end_event;所有 worker 结束、ws.close() 返回后,该次循环通常正常结束,随后执行 backoff(0) 即 0 秒后尝试下一次连接。只有异常穿透到 main() 的两个异常分支时才增加 conn_retries、随机退避并删除 LastAthenaPingTime。

    跨会话的隐患在于队列:recv_queue、send_queue、log_recv_queue 和 upload_queue 是模块级对象,新会话重建线程却不重建这些队列,旧请求、回复或日志响应若在断线时留在队列,可能被新会话处理。cur_upload_items 在每次成功连接后清空,但上传等待队列不清空。这属于条件性时序风险,不是本轮复现结果。

    send_queue_push() 对发送项使用 (priority, next(send_seq), data),同优先级由递增序号破同分,高优先级 RPC 回复可先于低优先级日志;队列未设置 maxsize,若生产速率长期超过发送速率,内存增长需要实测并设平台预算。此外 handle_long_poll() 逐个 join() 没有超时,上传回调、日志响应等待或阻塞的 WebSocket/Params 操作若不及时响应结束事件,会延迟整个会话清理和重连。

    硬件与插件化验收

    把主连接封装为带连接代号的会话插件:每条 RPC 请求、响应和诊断确认绑定代号、deadline 与幂等键;重连时显式取消、迁移或重试,不能让旧队列消息无条件进入新连接。把「进程存活、TCP 已连、服务端鉴权成功、最近 Ping 年龄、RPC 可用、上传可用」拆成独立健康指标,deviceState.lastAthenaPingTime 只能作为其中一项时间戳输入。

    对队列长度、帧与消息字节数以及每类 worker 的执行时间设上限;所有 worker 应支持有限时间退出与可观测的清理结果;收发失败需区分未发送、部分发送、已发送未确认。台架验收应覆盖:仅业务帧无 Ping、静默链路、半开 TCP、快速反复断连、不同线程故障、Params 写失败、上传/日志等待阻塞、旧响应跨会话以及行车与驻车切换。现有硬件门控测试预期驻车约 60 秒、行车约 21 秒内触发重连并重新收到 Ping,但本轮未运行。

    常见问题

    Ping 超过 70 秒没来就一定会断开吗?

    不一定。70 秒门不是独立定时器,只有在 recv_data() 抛 WebSocketTimeoutException 时才检查;当底层持续返回业务帧却没有 Ping 时,循环不进入超时分支,不会检查 Ping 年龄。

    lastAthenaPingTime 能当作当前连接是否活着的判据吗?

    不宜单独使用。它只在收到 Ping 时更新,且收发 worker 正常退场的路径不会删除旧值;消费者必须用同一单调时钟域算年龄,并区分「历史最后 Ping」与「当前会话活着」,该值只能算一项输入。

    断线后旧队列里的消息会怎样?

    可能被新会话处理。recv_queue、send_queue、log_recv_queue、upload_queue 是模块级对象,新会话重建线程但不重建这些队列;cur_upload_items 每次成功连接后清空,但上传等待队列不清空。

    发送 4096 是固定 4096 字节吗?

    不是。ws_send() 按最多 4096 字符拆分首帧与 continuation,单位是 Python 字符,协议帧的实际字节长度并非固定 4096;队列也未设 maxsize,长期积压需要实测。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/ATHENA_WEBSOCKET_SESSION_LIVENESS_DEEP_DIVE.md(取用了组件与调用链表、70 秒 Ping 门、跨会话队列不清空、backoff 与插件化验收要求)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11