超时、推送和一个必然发生的竞态:手机备份到家里电脑的提交与确认
P-Pass 手机传照片给桌面这件事,中间有一步叫 flow.fetch:手机跟桌面说”我有一张照片要给你”,然后在同一次网络请求里死等,直到桌面把这张照片整个拉完、落盘、确认,才收到一句回音。
这个设计在同一个 Wi-Fi 下从来没露过馅。局域网延迟是毫秒级的,一张照片传完最多几秒,“等一下”感觉不出来。
它在跨网络、走中继(relay)的场景下会出一个很具体的问题:手机这边给这次等待设了 15 秒上限。中继是两端打洞失败时的转发兜底,我们用的是 iroh 的公共中继,由 number 0 运营,只转发加密数据。传一张几百 KB 的截图,15 秒绰绰有余。一个 288MB 的视频就不一样了:一次手机连蜂窝热点、电脑留在原网络的测试里,手机连续三次报了同一句错误:
DaemonUnreachableException: flow.fetch: no response from the computer within 15000ms
然后这一项被标成需要用户处理。只要传输本身超过 15 秒,这次请求就一定会超时,跟网络好坏无关。更早 backup.begin 撞上同一个 15 秒时,真机上的表现是照片算完哈希以后要等很久,才重新发起传输。
超时的修法是把”提交”和”等结果”拆开。拆开之后又冒出第二个问题:内容去重命中时,每张照片反而要多等 30 秒,原因是一个每次都会输的竞态。
一个超时值要同时管两件事
手机侧的连接客户端里只有一个常量:
// DaemonClient.kt
private const val CONNECT_TIMEOUT_MS = 15_000L
它同时用在两个地方:建立连接,和单次 RPC 等响应。而 flow.fetch 这次 RPC 的响应要等整个传输做完才回来,等于把”等传输”也塞进了同一个 15 秒。
这个超时值同时要服务两个完全相反的目标:
- 如果桌面真的死了、断线了,我们希望尽快知道,好去重试或提醒用户;
- 如果传输是正常的,只是文件大、网络绕了远路,我们希望耐心等下去,不要在它明明进展正常的情况下把它判死。
一个数字不可能同时满足这两件事。调大它,真正断掉的连接要更久才能被发现;调小它,大文件必然被误杀。项目里登记过一个按调用类型分档的过渡方案:建连 60 秒,控制 RPC 保持 15 秒,flow.fetch 给一根 30 分钟的保险丝,并且禁止按文件大小去推算超时。这个方案至今没有实施,它从一开始就只算止血,真正的解法是让这个数字不再同时承担两种含义。
“提交”和”等结果”,不该是同一次请求
往回退一步看,手机想做的其实是两件独立的事。一件是告诉桌面我要传这个,另一件是知道传完没有。
这两件事被焊在了同一次 RPC 里,所以这次 RPC 的时长下限,被”传输本身要花多久”这个完全不可控的变量决定了。而”传输花多久”和”这次网络请求该不该超时”根本是两个维度的问题。
这个形状在 HTTP 里对应 202 Accepted 的用法:提交任务,拿句柄,回头拿句柄查状态。查状态这个动作本身很快,不管背后的任务跑了多久。项目里的 timeline.subscribe 早就是这个形状:推一条 timeline.invalidated 做加速,再靠定期对账兜底。这次是把它搬到手机传照片这条路上。
offer 立刻返回,status 负责查账
新的流程是三步。新的第一步不再叫 fetch,改叫 offer:只登记意图,不搬字节。
手机: flow.offer(登记这次传输意图) → 桌面立刻回应,不等传输
桌面: 后台把传输任务 spawn 出去异步跑,终态写进账本
手机: 隔一段时间 flow.status(查账本)→ active / completed / cancelled / failed / not_found
flow.status 是一次很轻的查询,只读数据库和一个内存里的”任务是否还在跑”的标记,不碰真正搬字节的那条数据面连接。所以不管这次传输实际要跑多久,status 请求本身在正常情况下都能在控制类超时(还是那个 15 秒,但现在它只管这一次轻查询)内返回。网络抖动时它照样会超时。
整个实现按一条原则写:手机只认账本,不认回声。一次 status 查询超时,不等于传输失败。它只说明这次网络往返没成功,传输到底怎样,要看桌面账本上写的状态。下一轮再查一次账,如果账本仍是 active,就继续等,绝不因为一次查询超时就重新发一遍 offer、跟桌面正在跑的传输互相打架。
// NativeFlowDeliveryPort.kt(示意)
// 一次 status round-trip 失败 ≠ 传输失败;
// 只有账本明确写着 failed/cancelled,才是终态。
fun flowStatusPollOutcome(reply: FlowStatusReply?, consecutiveTimeouts: Int): PollOutcome
暂停和取消也借着这次分清楚了。之前”挂断连接”一个动作兜三层含义:租约还在吗,任务还在跑吗,用户还要不要。现在意图变化直接写账本,观察就是去查一次账,没人再靠”这通电话还接没接着”去猜。暂停留下的字节继续受保护,恢复时接着传;取消就是真放弃,那些字节随后被垃圾回收。
测试和真机验证
daemon 侧加了个测试,在数据面拉取里注入一段远超控制超时的延迟,断言手机侧必须靠轮询走到确认态、拿到回执。这个测试在改动前是真红的。
更硬的一个反证:临时把”记录当前任务,好让 suspend 能找到它去中断”这一行代码删掉重跑测试,suspend 变成了一个什么都没做的空操作,传输在后台悄悄跑完,测试断言的”必须收到明确的中断结果”直接失败。恢复那一行,测试立刻回绿。说明这个测试确实卡在 suspend 的中断路径上。
真机那一轮更直接。三星手机,全新配对,一个 224MB 的视频加 23 张照片,24 项全部确认。视频在账本上从 active 走到 completed,跑了 80 多秒,全程一次失败都没有。按旧代码的形状,这个视频的 flow.fetch 在第 15 秒就会被判超时。
拆开之后,去重的照片反而慢了
拆成 offer 和 status 之后,又补了一个能力:传输前查重。桌面库里已经有同样内容的照片,就没必要把字节再拉一遍。实现放在 offer 的处理里,在决定 spawn 传输任务之前先查一次内容库:
- 命中:不碰数据面,直接走和真实传输成功后同一条收尾路径,生成回执、写账本,然后发一条
flow.delivered事件通知手机; - 未命中:照旧 spawn 传输任务。
这条路径在一次断链重连后的真机测试里被大量触发:重新发起备份,11 张照片全部命中去重。按设计它们应该瞬间完成,实际上每一张都等满了 30 秒,11 张用了约 3 分钟。
30 秒这个数有明确来历。手机判断”传完没有”按优先级看三个信号:桌面推来的 flow.delivered,手机本地 iroh-blobs 发送端的状态,最后才是兜底去问一次 flow.status。兜底的触发条件原本是”本地发送端空闲超过 30 秒”。去重命中意味着数据面一个字节都没走,本地发送端从头到尾没有任何事件,空闲时长一直是空值,永远到不了阈值。之前一次修复给这种情况补了出口:本地从未有过信号时,按等待循环自己的挂钟计时,到 30 秒也去问一次 status。
所以每张 30 秒说明了一件事:这 11 次里,flow.delivered 推送一次都没送到手机。只要送到过一次,那一张会在推送分支里立刻返回,不会走到兜底。
推送为什么一次都没送到
手机侧的调用顺序是这样的,都在同一个协程块里:
// NativeFlowDeliveryPort.kt(简化)
val reply = desktop.offer(request) // 先 await offer
val pushChannel = Channel<FlowPushOutcome>(...)
launch { client.subscribeTimeline(...) } // offer 回来之后才订阅,而且是异步 launch
先 await offer,拿到应答之后才去订阅事件流。订阅又是异步 launch 出去的,这一行执行完的时候,订阅连接还没建好。
daemon 这一侧,去重命中时整条路径在 offer 的处理过程中同步走完:查到已有持久副本,写完成回执,紧接着发出 flow.delivered,然后才把 offer 的应答返回给手机。
事件总线是一个 tokio 的 broadcast::Sender,模块头的注释写得很直白:无订阅者时 send 直接丢弃,emit 里是 let _ = bus.send(...),错误不往外抛。这里要说准确一点:桌面壳自己也常驻订阅着同一条总线,所以 send 那一刻总线上通常是有接收者的。丢的原因在于这台手机的订阅那时还没建立,而 broadcast 只投递给发送那一刻已经存在的接收者,之后才订阅的人拿不到之前的消息,也没有任何补发。
把时间线排一下,结论是确定的:daemon 发出事件,早于它返回 offer 应答;手机收到 offer 应答,早于它开始订阅。事件永远在订阅之前发出,这个竞态每次都输,谈不上偶发。
真实传输时没暴露,是因为它要建连、要搬字节,daemon 发出 flow.delivered 之前至少要花掉这段时间,手机的订阅有足够的余量先建立起来。去重把这段延迟降到了零,窗口从”几乎碰不到”变成了”每次必中”。
去重本身也有一笔账。它用”零字节重传”换来了”每张多等一个兜底窗口”。对一个 224MB 的视频,这笔交换依然很划算;对一张几百 KB 的照片,改之前是浪费一点字节但确认及时,改之后字节省了,每张却要卡 30 秒,大概率是净亏。
为什么没有去调订阅的顺序
最直接的修法是把顺序倒过来:先订阅,等订阅确认建立,再发 offer。这能赢下这场竞态。
这个方案我们没有采用,理由是靠时序组合去赢一场竞态,本身就不是合适的修法。倒过来之后,正确性依然押在”订阅先于完成”这个时序上:得给订阅加一个建立确认,得处理断链重连期间订阅失效又重建的窗口,每多一个异步环节就多一个可能输的地方。而且推送在这套设计里的定位一直是加速,轮询和本地信号才是保底。为了让加速通道不丢,去给它补时序保证,方向就偏了。
项目里的时间线同步走过同样的路。那次定下的规则之一是”订阅即返回当前态”:订阅连接一建立,daemon 立刻推一次当前状态,客户端不需要”先拉全量、再单独订阅”两个动作,也就没有顺序错了会漏事件的真空期。另一条是事件本来就允许丢,客户端靠定期整页重新拉取兜底。这次的真空期出在 flow.offer 和手机订阅之间,解法沿用同一个思路。
让 offer 的应答直接带回当前状态
最后拍板的改法是:flow.offer 的应答改为返回 FlowStatusReply。换句话说,你 offer 完立刻调一次 flow.status 会得到什么,我就在 offer 的应答里直接给你什么。
202 这套模式的标准用法里,200/201 和 202 是按每一次请求决定的,并不按端点固定死。活当场干完了就直接返回结果,真要异步才回 202 加任务句柄。原来的代码是 daemon 手里已经有了答案,还坚持回一个”已受理,你去等吧”。
最贴近的先例是 Docker Registry v2 的跨仓库 blob mount。同样是按内容 digest 寻址、同样有去重短路:
POST /v2/<name>/blobs/uploads/?mount=<digest>&from=<repo>
已有这份内容 → 201 Created,零字节传输,终态就在这个应答里
没有 → 202 Accepted + upload location,走正常上传
201 和 202 本身就是判别式。HTTP 条件请求的 304 Not Modified、Git 协议里客户端先报 have <sha> 的协商、rsync 先换校验和,都是同一个做法:发起方正在等的那个应答里,直接回答”我已经有了”,不把这个事实推给带外通知或轮询。
落到代码上,仓库里现成的 FlowStatusReply 形状正好合适:state、只在 completed 时有值的 receipt、task_running。offer 的三种结局对应三种应答:
- 命中去重:
state: "completed"加上手里已有的回执,手机零等待; - 正常起传:
state: "active"、task_running: true,手机走原来的等待循环; - 恰好输给一次并发的取消或暂停:按账本当前的真实状态回,不伪造回执。
daemon 侧”状态映射成应答”的逻辑抽成了一个共用的私有函数,status() 和 offer() 都调它,两条路径不会各写一套、日后漂开。手机侧连解析都不用新写,offer 返回后直接过轮询路径本来就在用的 flowStatusPollOutcome:completed 就当场落回执,不建订阅、不进等待循环;cancelled 就放弃本轮;只有 active 才走原来的流程。
有两个顾虑提前核实过。第一,回执可能到两次:应答里一次,推送如果赶上了再来一次。回执的处理本来就设计成幂等的,同一项已确认后再收到一次,不会多记一条审计事实。第二,新手机配旧 daemon 时,offer 的应答会是空值。这种情况手机必须明确报错,不许静默退回”等推送、等超时”的老路,那正是这次要消灭的隐形卡顿。
daemon 的单测覆盖了前两种结局。反证是把终态分支退回改前的形状(应答不带终态),两条相关用例立刻变红,“正常起传”那条保持绿。并发取消那一支,测试设施里没有能在两步之间精确插入一次取消的注入点,所以没有直接用例,它委托给 status() 已经覆盖过的那条映射。
验证:3.30 秒,以及推送到底有没有用
真机验收用的是同一台手机、同样那 11 张照片。手机重装后重新配对,库里已经有这 11 张,重新发现后全部命中去重:
| 场景 | 张数 | 总耗时 | 每张最大 |
|---|---|---|---|
| 改动前 | 11 | 约 3 分钟 | 30 秒 |
| 改动后 | 11 | 3.30 秒 | 0.396 秒 |
新的应答路径确实在跑,这一点有直接证据:手机侧对空应答加的那道硬失败,在整段日志里一次都没出现。如果桌面还是旧 daemon,它一定会报。
剩下一个问题:去重的洞绕开了,可订阅顺序没动,真实传输路径上的推送到底有没有送到过,前面的验收回答不了。真实传输的那几项同样很快,但本地 iroh 发送端的完成事件也能让手机在一秒内问到结果,两条路得出的耗时一样,分不出是谁结的账。
于是在手机侧三个结账点各加了一行日志,标明这一项是被谁结掉的:offer_reply、push 还是 status。不改任何行为,只加可观测性。然后选了一个从没备份过的相册:
| 轮次 | 项数 | 结账方式 |
|---|---|---|
| 真实传输 | 12 | 6 项 by=push,6 项 by=status |
| 重新配对后重发同一批(去重命中) | 25 | 25 项全是 by=offer_reply |
6 行 by=push 就是推送在真实路径上能送达的正面证据,不需要修。另外 6 项 by=status 也不是推送丢了:这 6 行日志里本地状态都已经是 Completed,等待循环才转了第一圈,耗时 1 毫秒。手机自己的发送端信号比推送先到,按”本地判活优先”的规则直接去问了 status,推送没赶上出场。全程没有一项碰到 30 秒兜底。
第二轮同时充当了反证。如果两种场景的日志长得一样,这套判别就是摆设;实际上真实传输只出现 push 和 status,去重只出现 offer_reply,三条路分得很干净。
常见问题
为什么 broadcast 发出的事件会丢?
tokio 的 broadcast 只把消息投递给发送那一刻已经存在的接收者,之后才调 subscribe() 的接收者只能收到订阅之后的消息。没有任何接收者时,send 返回错误,消息直接丢掉。它的定位是实时广播,要可重放得自己在上面加一层。我们这次的情况是总线上有别的订阅者,但手机的那个订阅还没建立,效果一样。
什么时候该用 202 这种先受理后查询的模式?
当一个请求要做的事,耗时取决于你控制不了的变量(文件大小、网络路径、排队长度),就不该让调用方在这一次请求里干等。先受理、给句柄、回头查状态,能让”这次网络请求该不该超时”只和网络有关。另一半同样重要:能当场给出结果时就当场给,别让知道答案的服务端也回一个 202。
先订阅再提交,不就没有竞态了吗?
在单次调用里是这样,但前提是订阅真的建立完成了,这通常需要一个确认往返。断链重连时订阅会失效重建,中间那段窗口还得另外处理。如果终态是在提交的同步处理里就产生的,最稳的做法是在提交的应答里直接带回来,推送留作加速。
建连超时和 RPC 超时要分开设吗?
它们回答的问题不同。建连超时回答”对端在不在、能不能连上”,受打洞和中继影响,天然比 RPC 慢;RPC 超时回答”这一次往返有没有回音”。把两者放在一个常量里,调任何一个都会影响另一个。更要紧的是别让 RPC 的等待时长包含业务本身的执行时长,那一部分交给状态查询。