结论: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 校验,但不证明生产环境可达或被利用;插件化时应改用受限路径或文件句柄映射。
延伸阅读
- Athena 上传队列的持久化与重试 —— 同一服务域的队列与重试语义。
- Athena WebSocket 会话存活 —— 承接分帧发送与重连后队列不清空的边界。
- 崩溃采集与上传边界:100 MB 过滤、0o100640 模式与 12 小时暂缓 —— 另一条日志外传链的同类「本地已处理不等于远端已保存」问题。
- 上传器的选择结果与退避 —— 承接优先级队列与失败退避。
- 路段下载的鉴权 —— 对照远端交互中的身份与关联校验要求。
参考资料
- 项目 02 ——
openpilot-research/ATHENA_DIAGNOSTIC_LOG_FORWARD_ACK_DEEP_DIVE.md(取用了转发函数的组件索引、4096/100/3600 三个阈值、发送标记先写、确认字段与路径拼接风险、插件化验收要求)。
说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。
更新记录
首次发布 2026-10-11 最近核对:2026-10-11