Skip to content

spec: TLA+ 模型(统一事件协议、跨运行时同步、远程租约、行生命周期、调用生命周期、节点托管行) #613

Open

Description

@eric8810

属于 #183 第四步(EventsHost.tla 在"再做",其余在"然后")。对应质量规范 Q9.1.2、Q11.2、Q8.3、Q5.1.1;统一事件的协议规格 §四、§五、§七、§九;风险 EV1–EV4(#612)。

为什么

先定下的一点:协议不承诺顺序一致

两个运行时各自登记同名的监听器,各自在 elsewhere 还没更新时走快速路径发出:每次分发只调用本进程的条目,任何与两个进程程序顺序一致的全序都排不出这两次分发。这是 §5.2 允许的。所以"一个逻辑总线"在模型里不写成对单表总线的精化,而是下面各条性质的合取:每次分发看到的条目有因果上的下界(RegisteredThenSeen)和上界(WithdrawnStaysOut),条目之间的先后处处一致(OneOrder)。协议规格 §4.4 和 guarantees.md 加一条"明确不保证"写明上面的情形,事件设计开头"结果都和所有监听器在同一个进程里一样"一句随之限定(Q5.1.1);配置 sees-all(预期被违反)用 TLC 的反例记下它。

第二点:同一进程里并发的两次发出没有先后。 EmitOrder 只约束有先后的发出(同一线程的程序顺序,或经同步建立的先后)。Go / Swift / Kotlin 在锁内取快照、解锁后才开始监听器,两个并发的发出方在两个监听器上的顺序可以不同,这是允许的。协议规格 §5.5 补这一句。

模型:specs/events/

宿主部分写成 EventsHost.tla,按名字、行、会话、条目 id 的集合写;下面三个模块和 #616 共用它。EventsHost.tla 把宿主的规则按协议写全:名字与前缀的匹配;export / import 的匹配与收窄;五种方式何时开始下一个调用(emit、parallel 一起开始,serial、bail 等上一个的结果,waterfall 等上一个监听器调用 next);过滤器对每个候选的真假。这些只是定义,不增加状态;三个模块的配置只用其中一部分,#616 用全的。宿主里 Rust 插件的登记、撤下与发出(含 rutis/*)是宿主的动作;等待环的拒绝(SyncWaitCycle)、取消、值发不出去是"可以发生"的选择,不由宿主模块判断。每个模块只含它的性质需要的动作。每个会话每个方向按序送达,延迟任意。

模块 参与者与动作 规模
EventsTable.tla 登记、撤下、elsewhere、两条路径的分发、跨进程调用(一个运行时经宿主调用另一个运行时的服务,被调用方在其中登记、发出;没有它 §4.4 第 3 条的因果无从发生)、rows.load 的回复(§4.4 第 2 条)、行卸载(rows.unload、rows.ended)、会话结束后以新会话重新开始(握手后先收到 elsewhere 的全集,§4.5) 宿主 + 2 个运行时,每个 2 行、最多 2 个条目、2 次分发;跨进程调用最多 1 次;会话重新开始与 rows.ended 分在不同的配置里。运行时发出 rows.ended 算作它已知的撤下
EventsEmit.tla emit 的顺序与队列:宿主的 emit 队列(等过滤器时排队,每项记着行);运行时本地的 emit 队列(快速路径上等过滤器时,本进程之后的同名 emit 排在后面,轮到时再判断路径,§5.5 第 4 条);R1 的同步等待:扣住的调用队列,扣住期间快速路径上的同名 emit 排在被扣住的之后(behind),等待期间可以执行等待链上的代码(登记、发出),控制帧(表操作、emit 的回复)照样就地处理。等待链上的代码发出的帧带着 R1 这次等待的 id;宿主发回给 R1 的 emit 调用带不带发出方的调用链都允许:发回 R1 自己会话的调用现在从不带链(caller_of);模型仍允许带链,代表另一个进程在 R1 的等待链上发出时到达 R1 的调用(#557 的真实来路),这样不必让 R2 发出。R1 等待期间就地执行带着它等待 id 的调用,其余扣住;修复后(#557),同名的更早的调用还扣着时,带链的调用也排在它后面。过滤器在发出时选定为"无""迟到"或"永不回答"。R2 登记、撤下并执行监听器调用,按 R2a、R2b 两种规则各一份配置 宿主 + R1(两行)+ R2;一个名字;R1 最多 3 次 emit
EventsLink.tla 节点连接:对端的 link.offers 是一串快照(含"还没提供 events"的那份),版本递增,接收方只保留最高的,所以会跳过中间的(peer.rs 每次宣告是一个任务),与宣告之间不保证先后;宿主接受一条宣告分三步(查代次、登记条目、插入),三步之间可以交错(link.rs 的 accept() 在查与插之间解锁);对端可以因另一个族的变化再发一份快照(#593 的时序就是这样出现的);宣告 id <epoch>.<n> 宿主 + 一个对端,两个族(events 和另一个),最多 2 次提供

运行时的规则分三种:R1 = Node(不可重入,同步等待时扣住);R2a = 读到帧就开始调用、同一个监听器的调用可以在 await 处交叠(Python、Bun);R2b = 每个监听器一个串行队列、一次调用跑完才开始下一次(Go、Swift、Kotlin)。两个运行时规则不同,不用 SYMMETRY。

R2a 对 Python 成立有一个前提:现在 Python 只原地处理 return / throw / cancel / release,events.call、events.elsewhere 都用 call_soon 推迟(peer.py 约 1375 行;runner.py _invocation 的注释说"下一帧读之前就开始",代码没做到),emit 的回复却原地处理。配置 python-deferred 按现在的做法建,预期违反 EmitOrder 或 FastPathByFrame;修复后改为通过。TLC 若找不到反例,这条疑点不成立,配置改为通过。

因果用幽灵变量记:每个进程和每条在途的帧带一个"已知的登记与撤下"集合。进程自己登记时加入的项只在本进程里算(程序顺序),不随帧传出去;宿主接受登记、处理撤下时加入的项,随宿主之后发出的帧传给别的进程;进程撤下(含发出 rows.ended)时加入的项,随它之后的帧传出。收帧时并入。

配置里不出现(EventsHost.tla 里有定义,三个模块的配置不用):前缀条目;export / import 的匹配与收窄;过滤器对单个候选的真假(配置里只有"全部保留,可能迟到"和"永不回答");parallel、bail、waterfall(配置里方式只有 emit 和 serial,后者代表"一次开始一个调用"的方式)。

不建模:rows.update(等价于撤下再登记);next 只能调一次之类的单进程规则(Q11.2.2);值、错误内容、AggregateError 的聚合(一致性套件覆盖);作用域(0.15.1 起只是过滤用的描述)。同步方式 bail、waterfall-sync 与等待环检测在 #614。

性质

名字 模块 内容 对应的保证
OneOrder Table 任意两个条目 e、f:开始了这两个调用的每次分发,都按同一个先后开始它们("开始"按分发方一侧算:调用本进程的监听器,或发出它的 events.call,§5.4;与 EmitOrder 的"开始"不同);两个条目都被宿主接受过时,这个先后就是它们在宿主上位置的先后(§4.3) events/one-bus、events/order
RegisteredThenSeen Table 分发在发出时已知某个条目的登记(同一进程里程序顺序在前,或经宿主接受这次登记之后的帧传过来),且到取快照时这个条目没有在任何地方被撤下,则快照包含它;两条路径都如此(§4.4 第 1、3 条) events/presence、events/hub-dispatch
RunningRowHeard Table 宿主把一行记为 Running 时,这一行 apply 期间登记的条目都已被宿主接受过(之后可能已撤下)(§4.4 第 2 条) 登记表没有对应条目,随模型补登记
WithdrawnStaysOut Table 分发方在发出时已知条目被撤下(events.off、rows.ended、rows.unload、会话结束),就不为它开始调用;宿主处理撤下之后不再为它发 events.call;条目在它的进程里撤下之后不再被调用(events.call 以已撤下回复)(§4.4 第 4 条、§7.1) events/hub-lifecycle、events/lifecycle
SettlesOnce Table 每个(分发,条目)最多一个 events.call;每个调用最多一个结果,不重试(§7.1 末条、§7.2);"至少一个"由终态检查 events/hub-lifecycle
EmitOrder Emit 对每个(发出的进程,名字),每个监听器上 emit 的调用按发出顺序开始。"开始"指监听器在它所在的进程里被调用:R1 扣住的调用以扣住结束后真正执行为准(§5.5) events/emit、events/one-bus(emit/order-* 用例)
FastPathByFrame Emit 运行时读到一次经宿主的 emit 的回复帧之后,这次 emit 不再让同名的后续 emit 留在经宿主的路径上(§5.5 最后一条) events/one-bus(emit/order-across-fast-path)
UnloadedRowEmitsNothing Emit 一行卸载之后,它排队的 emit 不开始任何调用;正在等过滤器的那一个,过滤器之后回答也不开始;宿主和运行时两侧都如此(§5.5) 登记表没有对应条目,随模型补登记
CurrentOffer Link 连接上没有在途的消息、对端没有待发的宣告时,宿主表里来自对端的条目恰好是对端当前 events 提供下宣告、仍然存活的条目(§9) events/node-links
NoEcho Link 宿主从不把引进的条目宣告回对端(一跳;a—b—c 由 events_nodes.rs 覆盖) events/no-multi-hop
不卡住 Table、Emit TLC 的死锁检查。每个行为都是有限的;R1 离开等待在等待期间一直可以发生,跨进程调用的被调用方一直可以返回。终态用 Done 原地停留,Done 要求:经宿主的分发都已回复,快速路径上的分发都已开始全部调用或被丢弃,每个开始了的监听器调用都有结果;只有两类例外——过滤器"永不回答"、它的行仍装载的那次 emit(连同它的选择器调用),以及排在它后面的:宿主上同一会话的排队 emit,运行时里同名的本地排队 emit。做法同 cross-runtime-sync,不需要公平性。EventsLink 的接收动作一直可以发生,不开死锁检查(CHECK_DEADLOCK FALSE) —
SeesAll(预期被违反) Table 每次分发都看到当时已发出 events.on 的全部条目。确认协议确实不承诺这一点,反例写进 README —

修复前的配置

模型在这些开关下必须找到反例,说明它抓得住已经发生过的问题:

配置 模块 修复前的行为 违反
before-reply-async Emit 运行时读到 emit 的回复后,过一步才记为已得到回复(c2c1b7e8 之前,运行时一半) FastPathByFrame
before-future-reply Emit 宿主对还在排队、调用尚未开始的 emit 先回复(c2c1b7e8 之前,宿主一半;#513) EmitOrder
before-held-emit Emit R1 同步等待期间扣住 emit 调用,快速路径上的同名 emit 抢在被扣住的之前(aa14b061 之前;a2748bf3 用 behind 修复) EmitOrder
before-later-overtakes Emit 等待链上较晚的 emit 越过较早被扣住的同名 emit(#557 之前) EmitOrder
before-unload-queue Emit 行卸载时不丢弃它排队的 emit(#536 之前,#564 修复) UnloadedRowEmitsNothing、不卡住
before-ended-order Table 宿主收到 rows.ended 后不进会话的有序队列,与之前的 events.on、之后的 events.dispatch 先后不定(#587 之前) WithdrawnStaysOut
before-offer-race Link 先于提供到达的宣告被尚未列出这次提供的 link.offers 撤下(#593 之前) CurrentOffer

现在的实现里的疑点(预期被违反;TLC 找不到反例则疑点不成立,配置改为通过):

配置 模块 现在的做法 预期违反
python-deferred Emit Python 推迟 events.call、原地处理 emit 回复(见上) EmitOrder / FastPathByFrame
accept-unlocked Link accept() 查代次与插入之间解锁,peer_offer 在中间撤下旧代次 CurrentOffer(旧条目在撤下之后插入并一直留着)

另外,每个性质配一个故意引入的错误(变异),做法同 remote-lease。

待复现的疑点(模型之外,用测试确认)

  • Node 的 behind 任务在发出它的行卸载时不丢弃(events.mjs 约 335 行交给 session.behind;session.mjs 只在 close()(约 191–198 行)丢弃排队的 behind 任务)。
  • Bun 的过滤器在同步调用里会泵帧,嵌套的较晚 emit 先开始。
  • Node / Bun / Python 的过滤器若是异步的(返回 Promise / 协程),被当作全部保留,不报错:按 docs(spec): 多语言运行时、同步调用、挂载、事件与值的规格按代码修订 #637,rutis 自己的 filter(predicate) 改为这次分发失败(Cordis 的 Context.filter 照 Cordis)。

规模与运行

  • 每个模块先只开一个动作族跑最小配置,再逐个打开;README 记下每一步的状态数。PR 上的配置每个模块 1 分钟以内,超出的放每周的运行(ci(nightly): 每天全量跑 install/dylib/specs/sdk-repro/events,频率过高 #576)。
  • events/run.sh 要认死锁("不卡住")、不变式与通过(cross-runtime-sync 的 run.sh 认这三种;remote-lease 的认时序性质、不认死锁)。

对应的实现

模型部分 实现
宿主的表、elsewhere、分发、emit 队列 crates/rutis-bridge/src/events/hub.rs
会话上 events.* 与 rows.ended 的帧序 crates/rutis-bridge/src/session/rpc.rs
节点连接 crates/rutis-bridge/src/events/link.rs;link.offers 与各族的 since 在 crates/rutis-bridge/src/peer.rs;Cordis 节点一侧 node/rutis-runtime/src/bridge/features.mjs
运行时:快速路径、扣住、emit 回复 node/rutis-runtime/src/events.mjs、session.mjs;bun/rutis-bun/src/events.mjs、session.ts;python/rutis/rutis/runner.py、peer.py;go/rutis/events.go;swift/rutis/Sources/Rutis/API/RuntimeEvents.swift;kotlin/rutis/rutis/src/shared/kotlin/arcships/rutis/internal/EventsRuntime.kt

产出

  • specs/events/:EventsHost.tla、EventsTable.tla、EventsEmit.tla、EventsLink.tla、.cfg、expected.txt、run.sh、README(中英:对应的设计、简化、性质、结果、每步的状态数)
  • 七个修复前的配置都找到反例;每个性质一个变异;sees-all 的反例写进 README
  • 协议规格 §4.4(中英)与 guarantees.md 加"明确不保证",事件设计(中英)开头那句随之限定;上表"随模型补登记"的两条登记进 guarantees.md,其余各条的验证一列加上模型
  • TLC 找到的新反例:修复,并转成 rutis-bridge/tests/event_hub.rs 的测试或 events.expected.json 的用例(所有运行时和测试工具都跑)
  • python-deferred、accept-unlocked 的结论:属实的修复(Python 改为原地处理 events.*;accept() 三步在一个临界区里),配置改为通过
  • 协议规格 §5.5 补"并发的发出没有先后"
  • 上面三条模型之外的疑点确认,属实的修复并加测试
  • CI specs 任务的矩阵加 events;specs/README.md 的表加一行

追踪表中归本 issue 的规则(#634)

范围是下面这些规则的全部,不只是正文举出的几条,共 87 条。完成时,应有形式化检查的规则在追踪表里都是"已覆盖"(形式化检查通过,并有变异或修复前的配置证明它不是空的;一致性用例写上用例名);只靠测试的写上测试名、保持"不适用";改为其他手段的写明理由。

这次补进来的:EVP-9-17(不带 epoch 的宣告 id 只按 id 替换),EventsLink 要加。规格的修订(Swift / Kotlin、作用域残留、rutis 的 filter(predicate) 不接受异步谓词)在 #637,模型照修订后的规格建。

  • 登记表 §6–§8a(13):events/order a、events/presence a、events/presence b、events/hub-dispatch a、events/hub-dispatch b、events/hub-flat b、events/hub-lifecycle a、events/hub-lifecycle b、events/hub-lifecycle d、events/node-links c、events/node-links d、events/one-bus a、events/no-multi-hop
  • 登记表 §9–§15(11):sdk/leaf-events a、sdk/leaf-events b、events/bun-runtime、events/emit-order、events/liveness、events/node-runtime、events/registration、events/table-watch、events/unload、events/via-host、events/withdrawn
  • 事件(56):EVP-4.1-2、EVP-4.1-5、EVP-4.1-6、EVP-4.3-1、EVP-4.3-4、EVP-4.4-1、EVP-4.4-2、EVP-4.4-3、EVP-4.4-4、EVP-4.5-1、EVP-4.5-2、EVP-4.5-3、EVP-5.1-1、EVP-5.1-3、EVP-5.2-1、EVP-5.2-2、EVP-5.2-4、EVP-5.3-14、EVP-5.4-1、EVP-5.4-2、EVP-5.5-1、EVP-5.5-2、EVP-5.5-4、EVP-5.5-5、EVP-5.5-6、EVP-5.5-7、EVP-5.5-9、EVP-5.5-10、EVP-5.5-11、EVP-5.5-12、EVP-5.5-13、EVP-5.5-14、EVP-5.5-15、EVP-5.5-16、EVP-5.5-17、EVP-5.5-18、EVP-5.5-19、EVP-5.5-20、EVP-7.1-1、EVP-7.1-2、EVP-7.1-3、EVP-7.1-8、EVP-7.2-1、EVP-7.2-2、EVP-7.2-4、EVP-9-4、EVP-9-8、EVP-9-13、EVP-9-14、EVP-9-15、EVP-9-16、EVP-9-17、EVP-9-21、EVP-15.29-1、EVD-1-1、EVD-1-2
  • 远程(7):REM-5.1-7、REM-5.2-10、REM-5.2-11、NOD-3-2、NOD-3-4、NOD-3-5、NOD-3-7

并入 #614:spec(cross-runtime-sync): 模型跟上等待图的修改(#561、#597)、每个调用一个线程的运行时和事件的同步方式

属于 #183 第四步(然后:按协议覆盖全部规则)。对应质量规范 Q11.2.3(设计修改时,模型同步修改);模型;多语言设计 §九;风险 EV5(#612)。

为什么

改动

每一项单独一个配置,MaxRoots = 2、MaxDepth = 3,不与 posts 叠在一起。Workers 除 policy-b-workers1 外都取 MaxRoots,线程数不成为卡死的原因。

  • 每个调用一个线程的运行时:常量 Threaded,这些运行时上每个进来的调用在自己的线程上执行,RustSucc 对它们不加"放到一边""栈顺序"两类边。配置 policy-b-threaded(n、g、p)检查不死锁与 NoFalseCycle。
  • 宿主作为参与者:执行 Rust 插件代码(Rust 服务、同步监听器、bail 的分发、Rust 自己发起的同步调用)的线程,个数是常量 Workers;读线程不算(events/hub-dispatch)。同步等待占着线程;链上的进来调用交给等待的线程(M1 §7.2),不在链上的要空闲线程,没有就排队。宿主自己发起的同步调用(path 为空)是一跳。Workers = 1 的宿主相当于一个不可重入的参与者;策略 B 不为宿主自己的线程建边(waits.rs 只按会话的对端记 Profile,进 Rust 服务的调用不是一跳),所以 policy-b-workers1 预期卡死,反例写进 README(例:Rust 的顶层调用同步调用可重入的 p 的 x,x 同步调用 q;这时另一个运行时 r 自己同步调用 p 的服务 s2,p 在等,把 s2 嵌套在 x 上执行;s2 同步调用 Rust 服务 S。S 不在前者的链上,要一个工作线程,而唯一的那个被占着;x 要等 s2 返回才能继续)。多语言设计 §九 和插件 API 文档写明(明确不保证,Q5.1.1):Rust 代码同步等待时占着一个工作线程;同时进行的这种等待占满工作线程、其中一条又在等一个不在它链上、要新线程的进来调用时会卡住,SyncWaitCycle 不检测。按实现核对 Rust 服务在哪个执行器上执行、同步调用是否占着它的线程;不占时这一项从"待定"删掉,配置改为预期通过。
  • 事件的同步方式:bail、waterfall-sync 建成宿主参与者上的一次执行:每个监听器调用是同步的 events.call,next 是回到宿主这条链上的调用。
  • 异步等待结果的分发:宿主参与者上的执行还有 serial(代表 parallel / waterfall):在一个已标记的调用之下执行(例如 Rust 服务处理一个运行时的同步调用时分发),每个监听器调用是异步的 events.call,执行等它的结果;开始第一个调用之前可以挂起一次(过滤器)。
  • 调用的分类按现在的 waits.rs 建模:同步调用(sync: true)记为一跳;调用链末尾是已标记的调用时,异步调用也记为一跳(854bef5d);其余的异步调用在有调用被标记时记为入口。运行时在处理一个已标记的调用时发出的异步调用分两种:结果要等的(await 之后才返回),是真实的等待边;发出即不管的,没有边。854bef5d 把两种都记为一跳,模型两种都建。控制通知(经 unchained 发出)不在任何栈上、不发起调用,不建模。"已标记链上的异步调用记为一跳"若违反 NoFalseCycle,就是现有的缺陷:修实现并加测试。
  • emit 的调用链:fix(bridge): keep wait-graph marks across slow dispatches and cancels (#537) #561 写明 emit 的过滤器和监听器调用在总线的任务上执行、不带调用链。main 上分三种:运行时发出的 emit,在读线程上就地开始、发往其他会话的调用带着发出方的调用链;发回发出方自己会话的调用从不带(caller_of);宿主 Rust 插件经 Bus::emit_erased 发出的不带(fix(bridge): keep wait-graph marks across slow dispatches and cancels (#537) #561)。模型的 RustPost、waits.rs 的模块说明("an events.call carries its emitter's chain")和多语言设计 §九 都说带着。按这三种逐一核对:实现对,就改模型、waits.rs 的说明和 §九,重跑 policy-b-posts*;模型对,就修实现并加测试。
  • 取消:调用方取消一个同步调用时,被调用方可能已经开始执行;标记保留到这个调用真正结束;调用方取消之后迟到的 ret 被丢弃(否则通道被堵住,TLC 报出假的死锁)。
  • 对应表补 Go、Swift、Kotlin:go/rutis/internal/peer/peer.go、swift/rutis/Sources/Rutis/Session/Peer.swift、kotlin/rutis/rutis/src/shared/kotlin/arcships/rutis/internal/peer/Peer.kt(现在 Go 不在表里)。
  • 本地放弃的同步调用:Go(ctx 取消)、Swift(Task 取消)、Kotlin(中断)放弃一个同步调用时不发 cancel,宿主留着这条等待边。模型加"调用方在本地放弃"的动作,检查 NoFalseCycle;被违反就让这三种运行时放弃时发 cancel。
  • Python 从别的线程发起的同步调用:经 run_coroutine_threadsafe,不带 sync 标记,宿主看不见这条等待。模型里作为宿主看不见的边,检查不死锁;被违反就让它带上标记,否则写成明确不保证。
  • Node 的细节:按键扣住(同键的 emit 调用不越过更早的)、behind 任务不在等待之上运行、等待链上对未完成 promise 的 await 得到 SyncWaitCycle、原地处理的帧。现在模型里的 Node 只有"只执行链上的,其余放到一边"。
  • Cordis 节点的连接会话:没有原地处理和扣住,每个 connector 一个泵;在一条连接上等待时不读别的会话,形成宿主看不见的跨会话等待。模型加一个这样的参与者;成环时宿主查不出,就写成明确不保证。
  • 跨主机的环:经节点连接跨两台主机的环,每台的等待图只看见自己那一段,检测不到。模型不建,写进多语言设计 §九 和 bridge/sync-wait-cycle-refused 作为明确不保证。
  • 运行时一侧的有界线程池(Kotlin 的 IO 池 64、Swift 的协作池)不建,README 的"简化"写明。
  • 新性质 MarkedWhileRunning:宿主收到、由 Rust 代码执行的同步调用,在它真正结束之前一直被标记;所以它之下发往其他会话的同步转发都是带调用方的一跳(发回来源会话的不记,同 waits.rs)。原有的性质(不死锁、RoutingSound、ChainRecognised、NoFalseCycle)不变。

修复前的配置

配置 修复前的行为 违反
before-host-call #597 之前:没有调用被标记时宿主自己的同步调用不记录,有标记时记为入口 预期通过:调用链完整时,顶层调用唯一可能的进入边是"栈顺序",那时已有标记,它被记为入口,入口有这类边,所以漏不掉环;#597 的单元测试对应调用链被截断的情形。README 写明
before-lost-chain 异步等待结果的分发挂起之后发出的调用不带链(#561 之前;实现里是 parallel / serial / waterfall 的分发 future、节点连接上的调用与 next 在第一次 poll 挂起、被 spawn 之后):它不再是这个已标记调用之下的一跳,监听器回调发出方时被放到一边而查不出环 不死锁
before-cancel 收到取消就删除标记(#561 之前):Rust 服务执行的同步调用被取消后仍在运行,之后同步转发一个调用 MarkedWhileRunning

产出

依赖 #624:sync_cycles 的 Kotlin 列。

追踪表中归本 issue 的规则(#634)

范围是下面这些规则的全部,不只是正文举出的几条,共 57 条。完成时,应有形式化检查的规则在追踪表里都是"已覆盖"(形式化检查通过,并有变异或修复前的配置证明它不是空的;一致性用例写上用例名);只靠测试的写上测试名、保持"不适用";改为其他手段的写明理由。

这次补进来的:SYN-9-3(可重入运行时的服务可能在它自己的同步调用中途被调用:加一条预期被违反的性质锁定,同 NeverCycle);SYN-9-26(未声明 sync-wait 的运行时不计入等待图:明确不保证,加配置锁定);SYN-9-30(会话结束时它的调用全部从表里移除:模型加会话结束)。§九 的文字按代码重写在 #637。

  • 登记表 §6–§8a(4):events/serial-bail c、boundary/5.6、events/hub-dispatch c、events/hub-bail b
  • 登记表 §9–§15(5):bridge/sync-wait-cycle-refused a、bridge/sync-wait-cycle-refused b、bridge/sync-wait-cycle-refused c、events/remote-sync、events/session
  • 事件(10):EVP-5.3-1、EVP-5.3-4、EVP-5.4-12、EVP-5.4-13、EVP-5.4-19、EVP-6-5、EVP-7.2-3、EVP-11-6、EVP-12.1-7、EVD-1-2
  • 会话(5):TAG-5-3、M1-5.3-4、M1-7.2-3、M1-7.2-5、M1-7.2-6
  • 运行时(28):SYN-5-13、SYN-9-1、SYN-9-2、SYN-9-3、SYN-9-5、SYN-9-6、SYN-9-7、SYN-9-8、SYN-9-9、SYN-9-10、SYN-9-11、SYN-9-12、SYN-9-13、SYN-9-15、SYN-9-17、SYN-9-18、SYN-9-24、SYN-9-25、SYN-9-26、SYN-9-27、SYN-9-28、SYN-9-30、SYN-9-31、SYN-9-32、MNT-3-15、MNT-5-9、MNT-11-6、REQ-5-6
  • 远程(5):REM-6.2-4、REM-6.2-10、REM-6.2-11、REM-6.2-15、REM-6.3-4

并入 #615:spec(remote-lease): 对应表补 Swift / Kotlin,加停止后再开始和故障之后的活性

属于 #183 第四步(然后:按协议覆盖全部规则)。对应质量规范 Q11.2.3、Q6.13.3;模型;风险 AH1、AH2(#612)。

为什么

改动

  • 对应表加 Swift、Kotlin、Cordis 节点的连接,拨出规则逐个语言列出(Go dial.go、Node dialing.mjs、Bun dialing.ts、Swift Remote/Dial.swift、Kotlin internal/remote/Dialing.kt、Cordis 节点 bridge/link.mjs),逐条核对:远程插件 §4.4 的接管顺序(鉴权 → 关闭旧会话 → 清理旧租约 → 回应新 hello);停止时取消重试;按失败类型等待(被吊销、4003 按认证失败等待,其余退避)。不一致的按 spec: TLA+ 远端租约接管、重连与吊销(#210) #432 的做法处理:模型里加修复前的配置,修复实现,加测试。
  • 停止后再开始:拨出方和监听端可以停止之后再开始(次数有限)。再开始时拨号次数和连接编号从头数,连接记为 <<c, 轮, n>>。监听端停止:关闭当前会话和排队的连接、清理租约、不再接受。StoppedStaysDown 的幽灵标记按轮记。新增 RestartIsFresh:控制方 Ready、拨出方 connected、监听端正在服务的连接,都属于各自当前这一轮;变异:按编号匹配握手结果,不看轮次。
  • 清理有截止时间:模型里清理旧租约改为"开始清理 → 完成,或超时被强杀"两步,检查强杀时 OneLease、OldCommandsStayOld 仍成立。实现上 Node / Bun 的强杀改为跟随 RUTIS_SHUTDOWN_TIMEOUT 加 2 秒(与 Go / Swift 重启时的做法相同)。
  • 新连接的心跳:Kotlin 接管时,新连接的心跳把等待旧清理的沉默也算进去,心跳超时 ≥ 30 秒时新连接被 1001 断开。改为从回应 hello 起算。
  • 故障之后的活性:拨号失败、断线(瞬时故障)总数不超过 MaxFaults;额度只算建立成功的连接,因故障而重拨的那一次不计,所以额度用完时,最后建立的连接还在,或者因故障断开后还能再拨一次。在这些配置上检查 EndsHeld,不用 SYMMETRY(TLC 带对称时检查活性可能漏报);规模取每方 2 次成功连接、MaxFaults = 2,不开吊销与停止。

产出

  • README(中英)的对应表和核对结论
  • RestartIsFresh 与它的变异;故障之后的活性配置;expected.txt;状态数
  • 核对出的不一致:修复前的配置、修复、测试
  • Node / Bun 强杀时间、Kotlin 心跳起算点的修复与测试
  • 疑点确认,属实的修复并加测试

依赖 #624:leases 的 Swift / Kotlin 列。

待复现的疑点

追踪表中归本 issue 的规则(#634)

范围是下面这些规则的全部,不只是正文举出的几条,共 55 条。完成时,应有形式化检查的规则在追踪表里都是"已覆盖"(形式化检查通过,并有变异或修复前的配置证明它不是空的;一致性用例写上用例名);只靠测试的写上测试名、保持"不适用";改为其他手段的写明理由。

这次补进来的:CHN-7-4(Incompatible 时控制方 link 停止自动重试);REM-7-5(承载验证身份,权限与拨号方向无关);静态监听 link 一组——DIA-3-2、DIA-5-13,以及 bridge/revocation-closes-only-its-connections a、CHN-1-4、CHN-8-5、CHN-8-8、REM-3.2-5、DIA-5-7、DIA-5-10 中静态 link 的一半(rutis 监听、运行时或对端拨入;接管先 old.end() 再 start;吊销后保留注册、处于 Waiting { AuthRejected };移交之后才发现吊销时以 4003 关闭):加一份配置,不再只当接入点的特例;DIA-5-11(吊销后以这个 id 打招呼的连接在 hello 之后以 4003 拒绝);INS-5-3、INS-6-3(实例里的节点连接:同一对端不同时接受同一节点的两条连接;随实例关闭断开、导入的服务撤销)。DIA-6-3、6-5、6-6、6-7 缺的拨出分类用例在 #639。规格修订在 #636,模型照修订后的规格建。

  • 登记表 §9–§15(6):bridge/dial-out-rules g、bridge/revocation-closes-only-its-connections a、loader/remote-runtime-lease a、loader/remote-runtime-lease b、swift/app-start-stop a、swift/app-start-stop b
  • 会话(10):CHN-1-4、CHN-5-6、CHN-6-3、CHN-6-5、CHN-6-6、CHN-7-4、CHN-7-7、CHN-8-5、CHN-8-8、CHN-10-10
  • 运行时(3):REQ-8-2、INS-5-3、INS-6-3
  • 远程(36):REM-1-4c、REM-1-4d、REM-2-3、REM-2-4、REM-3.1-2、REM-3-2、REM-3-5、REM-3.2-4、REM-3.2-5、REM-3.2-8、REM-4.4-1、REM-4.4-2、REM-4.4-4、REM-4.4-5、REM-4.4-6、REM-4.4-7、REM-4.4-8、REM-4.4-9、REM-4.4-11、REM-7-5、DIA-3-2、DIA-5-7、DIA-5-10、DIA-5-11、DIA-5-13、DIA-6-3、DIA-6-5、DIA-6-6、DIA-6-7、DIA-6-8、DIA-6-9、DIA-6-10、DIA-6-17、APP-4-1、APP-4-3、NOD-1-1

并入 #625:spec: 用 TLA+ 验证宿主与运行时之间的行生命周期(代次、rows.ended、拒绝、服务公告、宿主服务租约)

属于 #183 第四步(规格一项在"先做",其余在"然后")。形式化覆盖图 T4(#612);规范 Q11.2、Q9.1.2。

为什么

宿主和运行时之间的"行"有一套跨进程的规则:宿主按代次装载、卸载一行,运行时回报行结束、拒绝撞名的服务、宣布服务,宿主为行租用宿主服务。这些规则只写在代码里,没有设计模型,也没有成文的规格。修过的缺陷约 10 个,多数是一条跨代次规则被补进来:

先把这些规则写成规格,模型照它建,穷举它们之间的交错;历史缺陷用作"修复前"的配置。

模型:specs/row-lifecycle/

参与者:宿主的加载器(一行的代次、两阶段解析、拒绝记录、服务投影、宿主服务租约);一个运行时进程(它的行表;可以崩溃并以新会话重启);会话两个方向各自按序、延迟任意。

动作:rows.load(带代次)、rows.unload、运行时报告 rows.ended、运行时宣布 / 撤下服务(带版本)、宿主拒绝撞名的服务、hosts.provide / hosts.withdraw(租约)、行重启、运行时崩溃与重启、依赖出现与消失、解析从临时变为确定。

性质

名字 内容
OneGeneration 运行时里一行最多加载一个代次
OwnGenerationOnly 迟到的 rows.ended、拒绝、服务公告只作用于它自己的代次
NoProvisional 行不在临时解析上装载
ServicesBeforeRunning 宿主把一行记为 Running 时,它装载期间宣布的服务都已得到答复
NewerWins 更老的服务公告不覆盖更新的;撤下不丢
LeaseWithinRow 宿主服务租约在行卸载之后才释放,不比行活得长;失败的装载归还它的租约
WithdrawBeforeUnload 行的服务在它卸载之前撤下
活性 运行时在、依赖齐备、没有故障时,行最终 Active;被重启打断的刷新最终重做

修复前的配置:before-generation-key(a88fb65b 之前,不按代次记行)→ OwnGenerationOnly;before-ended-during-load(54a7e824 之前)→ OwnGenerationOnly;before-refusal-scope(d94aae9a 之前)→ OwnGenerationOnly;before-stale(6f8fb5c7 之前)→ NoProvisional;before-thread-announce(2f830f81 之前)→ ServicesBeforeRunning;before-projection-close(#372 之前)→ NewerWins。每个性质另配一个变异。

简化:一行加一个运行时;运行时的行处理按 Node 的规则(单线程、按帧序),其余语言的差异写进对应表;事件的登记与 rows.ended 的帧序在 #613,这里不重复。

对应的实现

宿主:crates/rutis-loader/src/runtime.rs(JsRow、代次、Refusals)、runtime/rows.rs;crates/rutis-bridge/src/runtime/process.rs(Imports、行的记账、租约)、projection.rs、rows.rs。运行时:node/rutis-runtime/src/runner.mjs、bun/rutis-bun/src/runner.ts、python/rutis/rutis/runner.py、go/rutis/runtime.go、swift/rutis/Sources/Rutis/API/Runtime.swift、kotlin/rutis/rutis/src/shared/kotlin/arcships/rutis/internal/Runtime.kt。

相关的疑点(读代码推出,未复现;建模时逐条确认)

  • Process::lease() 在等 hosts.provide 时被丢弃(行重启或 dispose),计数留在 1、没有 HostLease,服务永不撤下,之后同 id 的租约跳过 hosts.provide。
  • 运行时一侧:Go 的 tasks.Wait() 没有截止时间,晚到的 Ctx.Go 与 Wait 竞争;Kotlin 卸载清理没有总上限;Swift 在 dispose 快照之后到达的 rows.load 泄漏这一行;Python 线程里的 ctx.on / provide 与卸载是先查后做;Node 第二次 unload、endRow 或 dispose 不等已在进行的卸载;Bun 的 update 不发 events.off(现在只在 Cordis 的 volatile 字段上触发)。

完成

  • 规则写成规格:多语言设计或一份新的行协议说明(中英),guarantees.md 的 loader/ 条目验证一列加上模型
  • specs/row-lifecycle/:.tla、.cfg、expected.txt、run.sh、README(中英:对应的设计和代码、简化、性质、结果)
  • 六个修复前的配置都找到反例;每个性质一个变异
  • 上面的疑点逐条确认:属实的修复并加测试(宿主一侧进 rutis-loader/tests/runtime_rows.rs,运行时一侧进各语言的 row contract 测试)
  • CI specs 矩阵加 row-lifecycle

追踪表中归本 issue 的规则(#634)

范围是下面这些规则的全部,不只是正文举出的几条,共 88 条。完成时,应有形式化检查的规则在追踪表里都是"已覆盖"(形式化检查通过,并有变异或修复前的配置证明它不是空的;一致性用例写上用例名);只靠测试的写上测试名、保持"不适用";改为其他手段的写明理由。

这次补进来的:SYN-2-14 与 loader/runtimes-do-not-wait-for-each-other(运行时之间不互相等待,几个运行时同时冷启动各自就绪)、SYN-4-4(一个运行时崩溃,其他运行时、挂载与不用它的行照常):模型从"一行加一个运行时"扩到两个运行时;SYN-4-8(只改易变字段时就地 rows.update);MNT-4.2-3(服务公告在同步调用链内到达时,在调用返回前处理);REM-4.3-3(解析刷新在独立任务里,不在进行中的 reconcile 内同步等待)。REM-5.1-3、REM-5.3-5(节点托管行)改归 #640;M1-5.1-3、M1-14-1、M1-14-9(方法形状的推断)是一致性用例,改归 #639;SYN-2-13、SYN-5-2 的跨语言一致那一半也在 #639。

  • 登记表 §6–§8a(2):boundary/5.1a、boundary/5.8e
  • 登记表 §9–§15(15):bridge/exported-services d、bridge/caller-row a、bridge/caller-row b、loader/rows-gate-on-services b、loader/rows-gate-on-services c、loader/announced-services b、loader/announced-services c、loader/runtimes-do-not-wait-for-each-other、loader/runtime-down-stops-its-rows、loader/row-unload-leaves-nothing b、loader/row-unload-leaves-nothing c、loader/self-dispose b、loader/gated-row-failure、loader/instance-close-leaves-nothing、loader/remote-runtime-rows
  • 事件(2):EVP-7.1-4、EVP-7.1-7
  • 会话(22):M1-5.2-1、M1-5.2-2、M1-5.3-1、M1-5.3-2、M1-5.3-3、M1-5.3-5、M1-5.4-2、M1-5.4-3、M1-5.4-4、M1-5.4-5、M1-5.4-6、M1-6.1-1、M1-6.2-1、M1-6.2-2、M1-6.2-3、M1-6.2-4、M1-14-2、M1-14-3、M1-14-4、M1-14-8、CHN-5-1、CHN-6-5
  • 运行时(38):SYN-2-13、SYN-2-14、SYN-2-15、SYN-2-16、SYN-4-2、SYN-4-3、SYN-4-4、SYN-4-6、SYN-4-7、SYN-4-8、SYN-4-10、SYN-5-2、SYN-5-5、SYN-5-8、SYN-5-9、SYN-5-10、SYN-6-1、SYN-6-3、SYN-6-4、SYN-11-1、M2-2-4、MNT-4.1-2、MNT-4.2-2、MNT-4.2-3、MNT-5-6、MNT-5-8、MNT-6-1、MNT-6-8、REQ-1-2、REQ-4-2、REQ-5-1、REQ-5-8c、RTP-2-6、RTP-2-9、RTP-2-12、INS-4.2-5、INS-4.2-6、INS-6-2
  • 远程(9):REM-3-4、REM-4.2-3、REM-4.2-4、REM-4.2-5、REM-4.2-6、REM-4.2-11、REM-4.3-2、REM-4.3-3、REM-4.3-4

并入 #626:spec: 用 TLA+ 验证调用生命周期(取消、计数释放、关闭、读线程写回),定下取消之后的回复规则

属于 #183 第四步(然后:按协议覆盖全部规则)。形式化覆盖图 T5(#612);规范 Q11.2;风险 P4、P6、C4。

为什么

会话上的一次调用经过 Call → Return / Throw,中间可能有 Cancel、引用的计数 Release、会话关闭;经宿主中继时还多一跳。七个实现(Rust、Node、Bun、Python、Go、Swift、Kotlin)各自处理这些帧,有几条规则从没写下来:

  • 取消之后被调用方要不要回复。 协议只说"回复仍可能到达"。Rust 作被调用方时,排队中的调用和 await 被取消后不再回复(已在运行的同步调用照常回复);调用方的 cancelled 表要等迟到的回复才清,所以在 Rust ↔ Rust 的节点连接上只增不减。
  • 读线程写回会不会互相卡死。 读线程会写 Throw(decode_args、too_deep)、Release(丢弃迟到的回复、被取消的进来调用),中继的 Import 被丢弃时还会写到别的会话。通道都有界(内存 64 条、WebSocket 管道 4 MiB、socket 缓冲区)。两端读线程都在写、都在等对方读时会不会卡住,没有测试也没有论证。

修过的缺陷:48794010(通道拒收的消息只让它自己的调用失败,退回授予)、fa292fd5(回复写不出时持着写锁去关闭,死锁)、963d977b(放弃的分发没有取消用 future 回复的远程调用)。

模型:specs/call-lifecycle/

参与者:两个端点,加一个中继(宿主);每个方向的通道按序、容量 1–2;每端一个读线程(读到帧就处理,可能要写回)和调用方。

动作:Call、Return、Throw、Cancel、Release(带计数)、Close;被调用方在取消之后选择回复或不回复(按实现分配置);中继转发调用、回复、取消、释放。

性质

名字 内容
SettlesOnce 每个调用恰好结束一次:回复、抛出、关闭错误,或调用方放弃
CancelReaches 取消沿两跳都到达被调用方,不丢
PinnedTarget 已接纳的调用的目标在它结束前不被释放;旧的 Release 不拿走新的授予
BoundedTables 两端都正常时,调用表、取消表、导出表都有界,静止时为空
NoUnknownCall 两个正常的端点之间不会出现"回复了未知的调用"而关闭会话
不卡死 两端读线程都写回时没有因背压形成的死锁(TLC 死锁检查)

简化:值只建引用;一个引用最多被授予两次;同步调用与等待图在 #614,这里只建异步调用。

结论写回

  • 定下"取消之后的回复规则",写进会话协议(中英)和插件 API,七个实现照此修改;不回复的实现,调用方在收到取消确认或会话结束时清掉 cancelled。
  • 如果模型证明读线程写回会卡死:改成读线程不直接写(交给写线程或有界之外的队列),并写明。

对应的实现

crates/rutis-bridge/src/session/rpc.rs(Calls、cancel()、Frame::Cancel、Exports / Import、close())、rpc/relay.rs;各语言 session.mjs、session.ts、peer.py、internal/peer/peer.go、Session/Peer.swift、internal/peer/Peer.kt。

完成

  • specs/call-lifecycle/:.tla、.cfg、expected.txt、run.sh、README(中英)
  • 每个性质一个变异;"被调用方从不回复"的配置找到 BoundedTables 的反例
  • 回复规则写进协议并在七个实现里落实,session::testing 的一致性检查加上这一条
  • 读线程写回的结论写进 README;要改的话修实现并加测试
  • CI specs 矩阵加 call-lifecycle

追踪表中归本 issue 的规则(#634)

范围是下面这些规则的全部,不只是正文举出的几条,共 53 条。完成时,应有形式化检查的规则在追踪表里都是"已覆盖"(形式化检查通过,并有变异或修复前的配置证明它不是空的;一致性用例写上用例名);只靠测试的写上测试名、保持"不适用";改为其他手段的写明理由。

这次补进来的:MNT-6-5(dispose 同时启动插件卸载与在途调用的排空,不先等调用结束)。取消之后的回复规则定下后写进 #635 的会话帧规格。

  • 登记表 §6–§8a(2):core/D30e、boundary/5.8b
  • 登记表 §9–§15(11):bridge/in-flight-call-fails-once、bridge/no-call-on-a-dead-session、bridge/cancel-reaches-callee a、bridge/cancel-reaches-callee b、bridge/multihop-cancel-and-release、bridge/replies-match-calls、bridge/release-count、bridge/reference-release-count a、bridge/reference-release-count b、bridge/revocation-closes-only-its-connections b、bridge/cancellation
  • 事件(2):EVP-5.7-1、EVP-10-4
  • 会话(16):SES-7.1-3、SES-8-6、SES-8.2-3、SES-8.2-6、SES-10-4、SES-10-8、SES-10-9、SES-10-12、SES-10-13、M1-7.1-1、M1-7.1-2、CHN-2-9、CHN-4-7、CHN-4-9、CHN-4-10、CHN-5-5
  • 运行时(13):SYN-5-11、MNT-2-11、MNT-3-8、MNT-3-9、MNT-3-12、MNT-3-13、MNT-3.1-5、MNT-3.1-7、MNT-6-5、MNT-6-6、REQ-6-1、REQ-7-4、REQ-7-5
  • 远程(9):REM-1-4c、REM-3-3、REM-3.2-3、REM-5.2-15、REM-6.2-5、REM-6.2-7、REM-6.2-8、REM-7-2、DIA-5-8

并入 #640:spec: 用 TLA+ 验证节点托管行(peer rows)

属于 #183 第四步。追踪表 #634;形式化覆盖图 T6(#612);规范 Q11.2。

为什么

节点托管行(peer:<节点>/<插件>,一个节点经连接替另一个节点运行行)已经实现、已经登记(loader/peer-rows a–c),规则写在远程插件 §5.1–§5.3。之前定为"等 peer 设计恢复、定稿之前再建";按现在的覆盖范围(登记表 + 规格的全部规则),已实现、已登记的规则现在就要覆盖。它满足 Q11.2.1:两个节点、连接会断开重连、提供(link.offers)与装载交错,错误只在特定交错下出现(#435、#437、#438 都是这一类)。

模型:specs/peer-rows/

参与者:两个节点 A、B(各自可以替对方运行行),一条连接(可断开重连);A 的 PeerRowsPlugin(解析、离线 / 在线、schema 刷新)与 PeerRow(apply 发 plugins.load,disposer 发 plugins.unload);B 的 Host(每个会话一张已装载表)。

动作:连接建立 / 断开 / 重连;对端提供或撤回 plugins 族(带 epoch);行 apply、dispose、restart;宿主 dispose;装载在途时被取消。

性质

名字 内容
OneLoadPerSessionRow 对端每个(会话,行)最多装载一次
StopsWithLinkOrHost 连接或宿主走掉时,托管的行停下等待;断链后的卸载按成功处理
ReloadOnNewOffer 对端以新 epoch 提供 plugins 时重新装载;撤回时撤销 PeerRows,Peer 与其他功能照常
OfflineWaits 对端没有 plugins 或离线时,解析标为离线、行等待 PeerRows,不标 Unresolved;门控开放前不启动旧解析,每行只启动一次
NoOrphan 行在装载中被取消、或卸载先于装载到达时,对端不会留下没人管的插件
不互相等待(活性) 两个节点互相代装、同时冷启动或重连时都不死锁,宿主不等行就绪

修复前的配置:#438(PeerRows 在行排上之后、应用之前被撤下,行停在 Failed,#439 修复);#435 / #437(同时登记的两个族被宣告成两次提供,peer: 行被重启)。

待复现的疑点

规则(12 条)

  • 登记表 §9–§15(3):loader/peer-rows a、loader/peer-rows b、loader/peer-rows c
  • 远程(9):REM-3-6、REM-5.1-3、REM-5.1-5、REM-5.3-1、REM-5.3-3、REM-5.3-4、REM-5.3-5、REM-5.3-6、NOD-4-1

相关的一致性用例(REM-1-3、REM-3-1、REM-5.1-2、REM-5.1-6、REM-5.2-6、NOD-4-1 等)在 #639。

对应的实现

crates/rutis-loader/src/peer.rs(PeerRowsPlugin、PeerRow)、crates/rutis-bridge/src/host.rs(Host::{describe,load,update,unload})、crates/rutis-bridge/src/compose.rs。

完成

  • specs/peer-rows/:.tla、.cfg、expected.txt、run.sh、README(中英)
  • 两个修复前的配置都找到反例;每个性质一个变异
  • 疑点确认,属实的修复并加测试(rutis-loader/tests/peer_rows.rs)
  • CI specs 矩阵加 peer-rows;追踪表对应行改为已覆盖

Activity

  1. added this to the 0.18 milestone on Oct 25, 2026
  2. changed the title [-]spec: 用 TLA+ 验证统一事件协议(登记的传播、快速路径、emit 顺序、卸载与断开、节点连接)[/-] [+]spec: TLA+ 模型(统一事件协议、跨运行时同步、远程租约、行生命周期、调用生命周期、节点托管行)[/+] on Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions