标签: Athena

  • Athena 日志转发:4096 字符分帧、100 秒等待与 1 小时重发门槛

    结论:Athena 诊断日志转发是在 swaglog 已落盘之后的独立一段:非 PC 会话线程每约 10 秒扫描 swaglog_root,把候选文件整份读入、组装 RPC、按 4096 字符拆成 WebSocket 帧发送,并等待最多约 100 秒的成功响应。三个数字决定重发行为:4096 字符分帧、100 秒确认等待、3600 秒重发门。要注意两个非持久化事实:写入 user.upload 的发送时间只是「已尝试」标记,不是云端交付证明;确认只依据响应 JSON 的 result.success,不核对文件哈希或服务端对象键。而响应里的 id 会被直接拼进日志根路径,log_handler() 未校验它是否为本进程已发送的普通文件名。

    转发链:从轮转文件到 WebSocket 分帧

    本地来源是 SwaglogRotatingFileHandler 按 INFO 以上轮转落盘的文件,转发线程读的是完整文件而非逐条 logMessage。下表是这一段的关键函数与职责。

    函数 / 组件 实现职责
    SwaglogRotatingFileHandler 把 INFO 以上日志轮转落盘;转发线程读取完整文件,而非逐条 logMessage。
    get_logs_to_send_sorted() 枚举 Paths.swaglog_root(),检查 user.upload,未标记或标记早于一小时的文件进入候选;排序后排除候选集末项。
    xattr_cache.getxattr()/setxattr() 按路径与属性名缓存读取;本模块 setxattr() 先清缓存再写属性。来自其他进程的属性改动不主动失效。
    log_handler() 非 PC 会话线程每约 10 秒重新扫描;每轮弹出一个候选,写发送时间、读全文件、组装 RPC,并消费确认队列。
    send_queue_push()、ws_send() 日志请求采用低优先级,队列按 (priority, seq) 排序;发送线程把消息按 4096 字符拆成 WebSocket 帧。入队不等于已写到网络。
    jsonrpc_handler()、is_response() 带 id 且含 result/error 的消息进入模块级 log_recv_queue;无专门的请求来源或连接代号关联。

    三个数字决定重发行为:4096 / 100 / 3600

    第一是分帧长度:ws_send() 把消息按 4096 字符拆成 WebSocket 帧,发送中失败时该消息已经从队列取走,源码没有针对这条发送失败即时重排,重发只能依赖一小时扫描。第二是确认等待:当前文件对应的响应最多等待约 100 次、每次 1 秒。第三是重发门:get_logs_to_send_sorted() 用当前墙钟秒数减去 xattr 里的发送时间,无标记或超过 3600 秒即视作可重发。

    阈值 / 取值 含义 边界
    4096 字符 ws_send() 的 WebSocket 分帧长度 发送中失败后消息已出队,无即时重排
    约 100 秒 等待当前文件成功响应的最长时间(约 100 次 × 1 秒) 期间收到其他 ID 的响应仍会处理其成功标记
    3600 秒 无标记或标记早于一小时才重新列为候选 失败后不会立刻重试
    2147483647 本地「收到成功响应」的哨兵值 正常当前时间下不会进入候选,但不是对象存储的签名回执

    还有一个排序细节:返回值是 sorted(logs)[:-1],排除的是当次可发送候选中排序最大的文件,而不是先在全部目录项中识别活动文件。如果最新活动文件已有哨兵值而不在候选集,另一个较新候选也会被排除——这是源码排序条件的推导,文件名与实际轮转状态的一致性仍需验证。

    「已尝试发送」标记先写,打开可能失败

    log_handler() 从候选尾部取文件,先写当前 Unix 秒数到 user.upload,后打开并完整读取文件、调用 send_queue_push()。这个顺序带来一个明确后果:文件打开失败、进程中断、发送线程尚未发送、网络中断,都可能留下「已尝试」标记;而没有成功确认时要等一小时条件满足才能重新列为候选。探针也复现了这一点——打开日志失败前已经写入了「尝试发送」时间,且没有排入发送队列。

    此外,int.to_bytes(curr_time, 4, ...) 与 open(...).read() 之间没有事务,单个轮转文件会全部进入内存及 JSON 消息,这条路径没有专属的文件大小上限。对读属性时的 ValueError/TypeError 会被当成「未标记」,其他 OSError 交由外层循环记录。

    确认只信响应 JSON,不校验文件身份

    result.success 为真时,才把响应 id 对应的文件标成哨兵值;错误响应、超时或解析异常不会直接清理原发送时间。确认只基于响应 JSON 字段,不核对文件哈希、服务端对象键或持久化状态。由于每个请求的 id 就是文件名,而消费响应时未核对当前已发送的 ID 集合,这里存在可被误触的风险:响应 id 被直接传给 os.path.join(Paths.swaglog_root(), log_entry) 并用于 setxattr(),is_response() 只检查形状。

    探针显示:在没有当前发送任务时,形如 {"id":"../outside","result":{"success":true}} 的假响应仍会使 log_handler() 尝试对拼出的 /logs/../outside 写成功属性。这只证明函数没有已发送 ID 校验,不证明生产 WebSocket 可收到该响应、外部路径可写或服务器已持久化日志。插件化时正确的做法是把关联 ID 映射为宿主持有的文件句柄或受限路径,而不是用远端字符串拼路径。

    硬件与插件化验收

    源文档给出一条清晰的验收方向:诊断转发只能在非实时服务域运行,限制单文件与单次 RPC 大小,并定义网络费用、电量与存储写入预算,不影响控制周期与录制带宽;以本地生成的不可猜任务 ID 绑定文件身份、内容摘要和连接代号,独立记录 pending/sending/accepted/retry_wait,只有服务端可验证确认才推进终态;日志收集与远端转发分别授予能力,默认脱敏设备标识、位置、令牌和用户数据。测试上应在离线服务模拟器中注入发送前崩溃、分帧中断、延迟/重复/伪造响应、墙钟跳变、xattr 不可用、活动文件轮转及超大文件。

    常见问题

    写入 user.upload 的发送时间能证明日志已上传成功吗?

    不能。它只是「已尝试」标记,源码先写这个时间再打开并读取文件,因此文件打开失败、进程中断或网络中断都可能留下该标记。真正的成功标记是收到 result.success 后写入的 2147483647 哨兵值,而它也只是本地的成功响应标记,不是对象存储的签名回执。

    发送失败后日志会立刻重试吗?

    不会立刻。get_logs_to_send_sorted() 用当前墙钟秒数减 xattr 发送时间,无标记或超过 3600 秒即视作可重发;ws_send() 发送中失败时消息已经从队列取走,源码没有即时重排,重发依赖一小时扫描。

    为什么确认只等 100 秒,而不是一直等?

    源码对当前文件对应的响应最多等待约 100 次、每次 1 秒。期间即使收到其他 id 的响应,仍会处理其成功标记,直到收到当前 id 或等待结束;log_recv_queue 位于模块级,重连时不会清空。

    响应里的 id 被直接当路径用,有风险吗?

    有校验缺口。响应 id 会被直接拼进日志根路径并用于 setxattr(),而 is_response() 只检查形状、log_handler() 未验证这个 ID 是本进程已发送的普通文件名。探针证明函数没有已发送 ID 校验,但不证明生产环境可达或被利用;插件化时应改用受限路径或文件句柄映射。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/ATHENA_DIAGNOSTIC_LOG_FORWARD_ACK_DEEP_DIVE.md(取用了转发函数的组件索引、4096/100/3600 三个阈值、发送标记先写、确认字段与路径拼接风险、插件化验收要求)。

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

    更新记录

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

  • 4 个 worker 30 次重试:Athena 上传队列的持久化窗口漏在哪

    结论:Athena 的按远端请求上传用「4 个 worker + 优先队列 + Params 快照」实现,但它不是一张事务表:入队写盘、出队、取消、过期这些时点的快照并不一致,取消与过期分支都不会刷新快照,进程重启后已取消或已过期的任务可能重新出现。重试上限是 30 次、每次最长等待 10 秒,而 401/403/412 被归入终止分支、日志里也记为 success,不能当作远端已存储证明。以下为固定源码快照 c8fb906 的静态分析与隔离探针结论。

    队列与四个 worker

    这里讨论的是 Athena 按远端请求上传指定文件的队列,不是 loggerd/uploader.py 的常规路段上传。upload_handler() 由四个线程共享一个优先队列,取任务后依次处理取消、31 天过期、计费网络判断、PUT 响应与异常;线程数由 handle_long_poll() 固定创建四个,不受 RPC 的 HANDLER_THREADS 配置影响。main() 进程启动时只恢复一次队列,每次 WebSocket 会话创建上传线程,重连成功后清空当前任务字典。

    UploadFile、UploadItem 负责 RPC 参数映射与队列任务;UploadItem.__lt__/__eq__ 只比较 priority,不以 ID 比较。因此优先队列不会因此去重,同优先级的 FIFO 顺序也没有由任务 ID 或入队序号明确保证。

    组件 入口 职责和边界
    UploadItem athenad.py RPC 参数映射与队列任务;比较只按 priority,不按 ID,队列不去重。
    UploadQueueCache athenad.py 启动从 Params AthenadUploadQueue 恢复;只快照等待队列并排除已取消 ID。
    uploadFilesToUrls() athenad.py 校验文件名与存在性、按 URL 去查询串去重、生成 ID、入队并写快照。
    upload_handler() athenad.py 四个线程共享优先队列,处理取消、31 天过期、计费网络、PUT 响应与异常。
    retry_upload()、cb() athenad.py 计数小于 30 时重排并持久化,随后最长等待 10 秒;回调只在状态年龄大于 1 秒时检查计费。
    listUploadQueue()、cancelUpload() athenad.py 列表合并等待与当前任务;取消只标记等待项,不中止当前传输,也不立即刷新 Params。

    入队去重与任务 ID 的真实边界

    uploadFilesToUrls() 会检查文件名与存在性:拒绝空名、绝对路径与包含 .. 的名字,但没有证明符号链接与真实路径受限。去重比较的是 file.url.split('?')[0],覆盖等待与当前列表,未比较文件内容、完整 URL 或 headers。它只防止入队当时可见的相同基础 URL。

    任务 ID 用包含创建时间的 str(item) 做 SHA-1,这不是内容摘要,也不是服务端事务 ID。因此这套去重与 ID 都不能充当跨重启或并发请求的幂等键——重启后旧快照可能把任务恢复回来,而同一文件换个查询串就会被当作新任务。

    结果码、重试与等待

    上传结果被分成两类:响应码为 200/201/401/403/412 时终止任务、不再重试;其他 HTTP 状态以及 Timeout/ConnectionError/SSLError 计入重试。retry_upload() 在当前 retry_count < 30 时重新排队与持久化,随后最长等待 10 秒;计数初始 0 可重排为 1,29 可重排为 30,已经是 30 时不再重排。

    计费网络与重连中止走的是不增计数分支:调用 retry_upload(..., False),符合上限判断才重排并写盘,但每次都仍按最长 10 秒等待。_do_upload() 在请求的 .zst 不存在而扩展名去掉后的原文件存在时,会边压缩边上传,使用请求提供的 URL 与 headers,覆盖 Content-Length,PUT 超时 30 秒。总体而言,名为 success 的日志事件包含 401/403/412,不能当作远端已存储的证明。

    结果 是否重试 说明
    200/201/401/403/412 否,终止任务 HTTP 返回后写等待队列快照;success 日志含 401/403/412,非远端存储证明。
    其他 HTTP 状态 是 计数加一后重排并写盘;计数 30 时不再重排。
    Timeout/ConnectionError/SSLError 是 同可重试失败路径,计数达到上限后无额外快照更新。
    计费网络/会话退出 中止但不增计数 调用 retry_upload(..., False),仍按最长 10 秒等待;进入时已达 30 则不重排。

    持久化窗口

    UploadQueueCache.cache() 的 block=True 只要求 Params 写入调用阻塞完成,不是远端上传回执,也没有把「等待、传输中、完成、失败」作为单个原子状态持久化。listUploadQueue() 是瞬时内存视图;缓存与列表分别直接访问优先队列内部数组,不能视作同一事务快照。

    隔离探针复现了几个必须知道的窗口:等待项已有快照后调用 cancelUpload(),内存列表隐藏该 ID,但 Params 中仍是旧快照,模拟重启后会重新进入等待队列;retry_upload() 先重排与缓存、再清 cur_upload_items[tid],在缓存调用期间查询列表可暂时看到同一 ID 的「等待」和「当前」两项。探针没有并发调度、真实持久化故障或 HTTP 请求,证明的是允许这些状态的局部执行顺序。

    路径 Params 快照时机 可观察结果
    入队 循环结束后写一次 RPC enqueued 表示入队,不是上传确认;多任务入队与写盘非原子。
    出队及上传中 出队时不写快照 原快照可能仍含该项;中断后重放既不能保证也不能排除。
    成功分支 HTTP 返回后写等待队列快照 当前字典直到下轮才置空,列表可能短暂显示已结束项。
    可重试失败 先入队再写快照,HTTP 分支随后再写一次 计数可 0→1、29→30;已是 30 则不再重排。
    取消/过期 两个提前 continue 分支都不刷新快照 旧快照可能仍有该项,进程重启后可能重新出现。
    其他异常 没有统一任务终结/缓存动作 是否重新出现取决于此前快照与其他线程写盘,不应推断为可靠重试。

    自制硬件与插件重构要求

    源文档给出一组建议:把上传置于非实时服务域,与 CAN/执行器控制隔离;文件能力使用目录 FD 或真实路径约束,目标 URL、重定向与 headers 用显式策略,不能把远端 RPC 参数直接授予任意插件。任务应定义 queued / leased / transferring / accepted / rejected / retry_wait / exhausted / cancelled / expired 等可持久状态,出队先记录租约、超时回收,并用单一事务或追加日志保存状态迁移。

    幂等应以服务端任务 ID 或文件内容摘要加目标对象键实现;重试需退避、抖动、总期限与错误分类,401/403/412 应有各自的鉴权/权限/前置条件结果,不能统一归为成功。取消 API 必须覆盖等待与传输中任务并持久化取消终态。验收要用离线测试服务与故障注入:四线程并发、每个持久化时点断进程、重复 RPC、计费网络切换、重连、缺失文件、符号链接、HTTP 拒绝与重试耗尽。

    常见问题

    RPC 返回 enqueued 是不是就说明文件已上传?

    不是。入队只在循环结束后写一次 Params 快照,enqueued 表示入队成功,不代表上传确认,多任务入队与写盘也不是原子事务。

    上传重试最多几次,间隔多久?

    重试上限是 30 次:retry_upload() 在 retry_count < 30 时重排,已是 30 则不再重排;每次重排后最长等待 10 秒。计费网络或会话退出中止时不增加计数,但仍按最长 10 秒等待。

    日志里出现 success 就等于远端存好了吗?

    不等于。名为 success 的日志事件包含 401/403/412,这些是终止分支的响应码;它们不构成远端已存储的证明,需要服务端回执另行确认。

    为什么重启后已取消的任务又出现了?

    因为取消与过期这两个提前 continue 分支都不刷新 Params 快照,旧快照可能仍包含该项;模拟重启时清空内存取消集并执行 initialize(),任务会重新进入等待队列。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/ATHENA_UPLOAD_QUEUE_PERSISTENCE_RETRY_DEEP_DIVE.md(Athena 上传队列、四个 worker、结果码与持久化窗口)。
    • 项目 02 —— openpilot-research/ 同目录探针记录:四项队列快照与五项上传回调计费门隔离探针结论。

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

    更新记录

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

  • Athena 远程 RPC 与 WebRTC:设备 JWT 不等于逐方法授权

    结论:设备身份 JWT 只是建立 Athena 长连接的「门票」,不是本机每个 RPC 方法的授权。本地方法映射没有逐方法角色/权限检查,getMessage() 只要求服务名存在于 SERVICE_LIST;而 WebRTC 本机服务默认监听 127.0.0.1:5001,其 /stream 请求体可自选数据通道名单。因此远程 RPC 与直播应归入独立、非实时的服务域,并把 sendcan 等控制路径排除在通用数据通道之外。

    连接、调度与故障路径

    manager_thread() 在 DongleId 缺失或为 UnregisteredDevice 时,不启动 manage_athenad/uploader。athenad.main() 用本地私钥签发身份 JWT,作为 jwt cookie 连接 ATHENA_HOST(默认 wss://athena.comma.ai),断开后以带随机抖动的退避重连。网络会话的服务端授权规则不在本地仓库中。

    组件 关键函数 边界
    接收 ws_recv() 文本/二进制帧进入共享 recv_queue;收到 ping 写 LastAthenaPingTime,超过 70 秒无 ping 触发重连。
    RPC jsonrpc_handler()、rpc.handle() 以方法名查全局 dispatcher,异常转 JSON-RPC 错误;本地方法映射没有逐方法角色/权限检查,也不验证 jsonrpc 版本字段。
    发送 send_queue_push()、ws_send() 回复高优先级、日志转发低优先级;按 4096 字符分 WebSocket 帧。共享队列在重连时仍是进程级对象,不能当成精确一次的 RPC 事务。
    活性 ws_manage() 根据 IsOffroad 调整 TCP keepalive;仅是连接活性参数,不决定 RPC 授权范围。

    rpc.handle() 对未识别方法返回 METHOD_NOT_FOUND,参数类型错误返回 INVALID_PARAMS,普通异常返回 SERVER_ERROR;这些错误映射是协议处理,不是权限策略。连接建立后 handle_long_poll() 会启动接收、发送、连接管理、四个上传、日志转发和可配置数量的 RPC worker;worker 将请求原文写事件日志,敏感载荷需另行评估。上传生命周期还有几处状态语义:HTTP 200/201/401/403/412 均结束该队列项,不能把队列移除等同于远端成功存储;cancelUpload() 只标记队列内 ID,正在执行的传输并未由该函数直接中止。此外片段创建在非 PC 平台要求 IsOffroad。

    RPC 能力清单

    能力 关键入口 源码约束与注意点
    设备与网络查询 getVersion()、getPublicKey()、getSimInfo() 查询版本、设备公钥、SIM/网络元数据;公开公钥不同于公开私钥,但返回数据仍须考虑设备隐私。
    消息读取 getMessage() 可订阅任一注册服务并等待一条消息。该 RPC 是广范围读能力,不能直接授予通用第三方插件。
    日志枚举 listDataDirectory()、scan_dir() 从 Paths.log_root() 递归列路径并按前缀筛选;递归时不跟随目录符号链接。
    指定 URL 上传 uploadFilesToUrls()、_do_upload() 队列入口拒绝空、绝对或含 .. 的文件名,检查文件存在,随后向请求中的 URL 发 PUT;没有目的域名 allowlist。
    上传生命周期 upload_handler()、cancelUpload() 过期 31 天丢弃、计费网络可推迟/中断、特定网络错误重试至最多 30 次。队列移除不等于远端成功存储。
    本地代理 startLocalProxy() 仅允许 LOCAL_PORT_WHITELIST={22}(8022 兼容映射到 22),再桥到 127.0.0.1;白名单约束本地目标端口,不是对 remote_ws_uri 的域名校验。
    直播 startStream() 固定宽路摄像头、输出 carState/deviceState;仅在持久 CarParams.notCar 为真时声明入站 testJoystick。

    WebRTC 本机入口与数据桥

    webrtcd.main() 默认监听 127.0.0.1:5001,HTTP handler 提供 /stream、/schema、/notify,但在 WebrtcdHandler._dispatch_request() 中没有独立鉴权检查。不能据此推断外部网络可直接访问默认监听地址;本机其他进程、端口转发或修改 --host 后的暴露面需要单独控制。

    handle_get_stream() 只要求 JSON content type。会话时限很具体:生成 Answer 限时 30 秒,建立连接限时 15 秒,普通会话最长 300 秒;包含 testJoystick 的 body 会话走不限时等待断开的分支。无会话 5 秒后 schedule_teardown() 清除 IsLiveStreaming。

    数据桥的关键不对称在于:远端 Athena 的 startStream() 固定了服务名单,但直接调用本机 /stream 的请求体可自选名单;StreamSession.message_handler() 对入站普通消息仅按本会话 bridge_services_in 名单过滤,代码中没有按主题风险等级的统一许可层。所以必须区分「默认 Athena 调用约束」和「本机服务自身约束」。

    /schema 仅检验字段属于 log.Event 且非废弃字段,/notify 向当前会话广播提供的 JSON;CerealOutgoingMessageProxy.update() 把订阅主题转 JSON 发数据通道。handle_post_notify() 的假通道探针确认:通知按当前会话列表逐个广播,某通道发送异常被忽略,即使没有会话也返回 HTTP 200 OK——该返回值只代表本机 handler 完成遍历,不是远端客户端已收到通知的回执。

    自制硬件与插件化要求

    1. 将远程 RPC 与直播纳入独立、非实时服务域。插件注册清单需声明具体 RPC、日志文件和 cereal 主题读写权限;不能把设备身份 JWT 直接作为所有本机方法的全权令牌。
    2. 本机 /stream、/notify 和桥接写主题应由受信任调用方认证并用固定 allowlist 约束;尤其是 sendcan、carControl 等控制路径不能成为通用数据通道的可写目标。
    3. 上传能力应限制可访问目录的真实路径、目标 URL/域名与重定向策略,并把「服务端接受」「传输完成」「HTTP 拒绝」「重试耗尽」表示为不同状态。
    4. 验证矩阵至少包括:断网/重连时队列隔离,恶意 RPC 参数与超大消息,本机端口被非预期进程访问,symlink/路径边界,计费网络切换,会话竞争与清理,权限拒绝及行车状态切换。

    以上均为建议的重构验收,不是当前源码已经提供的保证;现有测试并未证明服务端授权、HTTP 本机访问控制、敏感主题限制、外发 URL 范围或实车会话时序。

    常见问题

    设备 JWT 等于本机方法的授权吗?

    不等于。JWT 只用于建立 Athena 长连接;rpc.handle() 以方法名查全局 dispatcher,本地方法映射没有逐方法角色/权限检查。因此不能把设备身份 JWT 当作所有本机方法的全权令牌。

    getMessage() 有按主题分级的权限吗?

    没有。它只要求服务名存在于 SERVICE_LIST,没有按主题敏感度进一步分级。这是广范围读能力,不能直接授予通用第三方插件。

    WebRTC 本机服务是默认对外开放的吗?

    不是。它默认监听 127.0.0.1:5001,且 _dispatch_request() 中没有独立鉴权检查。但本机其他进程、端口转发或修改 --host 后的暴露面需单独控制,不能据此断言对外可访问。

    为什么说 /stream 的名单约束与 Athena 的不是一回事?

    因为远端 Athena 的 startStream() 固定了服务名单,而直接调用本机 /stream 的请求体可自选名单;message_handler() 仅按本会话 bridge_services_in 过滤,没有按主题风险等级的统一许可层。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/REMOTE_RPC_WEBRTC_BOUNDARIES_DEEP_DIVE.md(取用了 Athena 连接与调度、RPC 能力清单、WebRTC 本机入口与数据桥、插件化要求)。
    • 项目 02 —— openpilot-research/DEVICE_IDENTITY_REGISTRATION_DEEP_DIVE.md(仅用于确认设备 JWT 的来源与注册前提)。

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

    更新记录

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

  • Athena 片段导出:createClip 入队不校验时间顺序、码率与倍速

    结论:离线视频片段导出由 Athena RPC 排队:createClip() 入队、getClipState()/getClipChunk() 读取、deleteClip() 取消,工作线程把起止时间映射成连续的 60 秒分段,先写到 clips/.<filename> 点号临时文件,编码成功后再用 os.replace() 原子改名。关键边界是:入队层只校验 route、摄像头文件名与输出 basename,不校验时间顺序、bitrate 或 speedup,而 PC 回退路径没有 comma 硬件那套校验;硬件工作器按 floor(start*20) 到 ceil((start+duration)*20) 选帧,输出按 20 FPS 重建。本文为对源码快照 c8fb906 的静态阅读与假依赖探针,未在 comma 设备上运行 Qualcomm V4L2 解码/编码,也未生成实际 MP4。

    从远程请求到可下载文件

    VideoClips.createClip() 在非 PC 设备上仅在 IsOffroad 时接受创建;它校验 route、摄像头文件名和输出 basename,并按输出文件名维护任务。_worker() 依据起止时间映射连续 60 秒分段,先输出到点号临时路径,编码后确认仍是同一任务对象才 os.replace() 为正式文件;异常时移除当前队列项并尝试清理临时文件。

    _encode() 负责选后端:PC 用 ffmpeg concat/libx264,comma 硬件调用 encoderd --clip,码率由 Mbps 转 bps,并传递倍速与 JSON 元数据;子进程非零退出视为失败。注意片段与 uploader 的自动 qlog 上传不同:生成并经分块读取或显式上传接口交付,不能把 clips/ 目录出现当作云端已持有视频。

    边界 入口 已核对行为
    入队 VideoClips.createClip() 非 PC 仅 IsOffroad 时接受;校验 route、摄像头文件名与输出 basename;不校验时间顺序、bitrate、speedup
    工作线程 VideoClips._worker() 按起止时间映射连续 60 秒分段,先写 clips/.<filename>,编码后仍是同一任务才原子改名
    后端选择 VideoClips._encode() PC 用 ffmpeg concat/libx264;硬件调 encoderd --clip,码率 Mbps→bps,传倍速与元数据
    状态/读取 getClipState() / getClipChunk() 成品需 ffprobe 读到元数据且 requested_at 为数值才列 ready;同名内存任务覆盖磁盘 ready
    取消/删除 deleteClip() 先从内存队列移除,再在锁内终止同名编码进程,最后删除正式输出文件

    _available_ranges() 依赖任何以 camera.hevc 结尾的文件是否存在,据此推算名义 60 秒范围,并不探测真实视频长度;这就是「名义时长」与「实际可解码时长」分离的来源。

    RPC 层契约:隐藏名、负偏移与倒置区间

    四组原函数隔离探针确认了入队层与读取层的多处不对称。PC 分支可把结束时间早于起点、负码率和零倍速原样加入内存任务;非 PC 分支则在入队前要求 IsOffroad。.hidden.mp4 会通过 createClip() 的 basename 检查,却被 getClipChunk() 的「不得以点号开头」检查拒绝,_on_disk() 也跳过隐藏名。这种「可排队却不可读取」是层间契约不一致,而非异常参数一定编码成功。

    读取侧还有两处细节:getClipChunk() 接受等于文件长度的偏移并返回空数据,同时拒绝负数与非整数偏移;状态查询用 re.search 允许带前后缀的 route 字符串提取合法路段,而创建请求用 re.fullmatch。工作器探针还确认,请求 10–130 秒会映射到三个名义 60 秒段,向 _encode() 传相对起点 10 秒、时长 120 秒;真正如何拒绝倒置区间(如 60→-1 秒)未在探针执行,PC/硬件编码器的实际拒绝行为需另行验证。

    encoderd --clip 与硬件工作器

    encoderd.main() 仅在 comma 硬件编译分支接受 --clip,解析 OUTPUT START DURATION、--bitrate、--speedup、--metadata 与输入段,转入 encode_clip()。该函数要求非空输入、有限且非负的起点、正的时长/码率/倍速,并要求请求落在「输入文件数 × 60 秒」的名义范围内;它会跳过起点之前的整段,检查首段视频编码为 HEVC 且宽高有效。

    环节 实现 边界
    参数校验 encode_clip() 要求非空输入、有限非负起点、正时长/码率/倍速 名义范围 = 输入文件数 × 60 秒,不等于每段实际可解码时长
    解码 FFmpeg 解复用 HEVC,input_frame+1 作 token 排入 V4LDecoder.queuePacket() 捕获面 token 经 handleCapture() 还原源帧序号
    抽帧 按 floor(start*20) 到 ceil((start+duration)*20) 选帧,speedup 用全局帧偏移模运算 跳过的解码面立即归还,送编码器的面由回调释放
    输出 frame_id 与 timestampSof/Eof 从零按 20 FPS 重建 倍速靠抽帧实现,输出仍标注为 20 FPS,不保留原始 SOF/EOF

    V4LEncoder::encode_frame() 提交 NV12_UBWC 面,避免解码面过早重用;工作器发 EOS、等待已排输入有对应解码结果,最多连续五次空轮询,随后关闭编码器并检查实际抽帧数。VideoWriter::write() 用微秒时间戳写 MP4,析构时写 trailer 并移除 .lock。

    长片并行与失败边界

    对不足两个剩余段或时长小于 120 秒的请求,encode_clip() 串行处理;其余请求按接近中点的段边界拆成两个工作器:第一段直接写目标,第二段先写到已打开后 unlink 的临时 spool,两个工作器完成后按第一段编码帧数换算微秒偏移,回放第二段包并拼接。并行分支的 frame_offsets 保证倍速抽帧跨分片连续,第二段 codec config 被跳过而沿用第一段输出头。

    源码显式拒绝短的中间段,以免把源视频缺口误拼成连续时间;但工作器的成功条件比较的是实际读到的 input_frame 所推导的帧数,没有强制 input_frame >= end_frame。因此最后一段提前结束而仍有部分选中帧时,静态看可能以短于请求时长的文件返回成功,_available_ranges() 也会把末段按满 60 秒展示。这需要截断末段的实板样本或硬件回放来验证,不能仅凭退出码保证时长完整。

    常见问题

    createClip 会拒绝不合理的时间区间吗?

    入队层不会。PC 分支可把结束时间早于起点、负码率和零倍速原样加入内存任务;非 PC 分支只额外要求 IsOffroad。真正校验落在硬件编码入口 encode_clip(),而 PC 回退路径没有同一套硬件校验。

    clips 目录出现文件就等于云端已持有视频吗?

    不等价。片段由 Athena RPC 排队、生成,再经分块读取或显式上传接口交付,与 uploader 的自动 qlog 上传是两条路径;目录里出现文件只说明本地已生成。

    为什么导出的片段可能比请求时长短?

    工作器的成功条件只比较实际读到的 input_frame 推导帧数,没有强制 input_frame >= end_frame;末段提前结束而仍有部分选中帧时,静态看可能以更短文件返回成功,_available_ranges() 还会把末段按满 60 秒展示。

    倍速导出会改变帧率吗?

    不会标注变化。倍速通过全局帧偏移的模运算抽帧实现,输出 frame_id 与 timestampSof/Eof 从零按 20 FPS 重建,文件仍标为 20 FPS。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/CLIP_EXPORT_DEEP_DIVE.md(Athena 片段 RPC、worker 分段与 encoderd --clip 硬件工作器)
    • 项目 02 —— openpilot-research/V4L_DECODER_RECONFIG_SURFACE_LIFECYCLE_DEEP_DIVE.md(源文档指向的硬解端口重配与外借面归还专题)

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

    更新记录

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