- SessionMachine 加 mosh 三相(moshActive/moshParked/moshResuming)+看门狗兜底, mosh 接管即拆 SSH、后台冻结不重建、前台唤醒脉冲等 SSP 续(+5 单测) - bridge.go: Node.WakeUp()(tsnet InjectEvent+MagicSock.Rebind/ReSTUN) + MoshRelay.Rebind()(同端口重开 loopback、泵按 socket 生成容错) - iosclient.cc: g_mosh_last_heard 探针(导出 mosh_last_heard_ms) + SIGCONT 全屏重绘 - MoshSession: nudge()(SIGCONT) + lastHeardMs();SSHTerminalModel 前台脉冲三连 + healthy 轮询 + beginBackgroundTask;URL scheme 供无头前台唤醒 - 验证(vohive-vm over tsnet):15s 冻结→前台 wake pulse→1.3s recovered,<3s Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
203 lines
8.0 KiB
Swift
203 lines
8.0 KiB
Swift
import Testing
|
||
@testable import TXCore
|
||
|
||
@Suite("会话生命周期状态机")
|
||
struct SessionMachineTests {
|
||
|
||
@Test("正常连接流程")
|
||
func happyPath() {
|
||
var m = SessionMachine()
|
||
#expect(m.reduce(.connectRequested) == [.startConnect])
|
||
#expect(m.phase == .connecting)
|
||
#expect(m.reduce(.authenticating) == [])
|
||
#expect(m.phase == .authenticating)
|
||
#expect(m.reduce(.established) == [.notify("已连接")])
|
||
#expect(m.phase == .connected)
|
||
}
|
||
|
||
@Test("认证失败为终态,不自动重连")
|
||
func authFailedTerminal() {
|
||
var m = SessionMachine()
|
||
_ = m.reduce(.connectRequested)
|
||
let effects = m.reduce(.authFailed(reason: "密码错误"))
|
||
#expect(m.phase == .failed(reason: "密码错误"))
|
||
#expect(effects.contains(.teardownTransport))
|
||
// failed 态收到网络事件不动
|
||
#expect(m.reduce(.transportClosed(reason: nil)) == [])
|
||
#expect(m.phase == .failed(reason: "密码错误"))
|
||
}
|
||
|
||
@Test("断线进入退避重连")
|
||
func disconnectSchedulesReconnect() {
|
||
var m = SessionMachine()
|
||
_ = m.reduce(.connectRequested)
|
||
_ = m.reduce(.established)
|
||
let effects = m.reduce(.transportClosed(reason: "eof"))
|
||
#expect(m.phase == .waitingToReconnect(attempt: 1))
|
||
#expect(effects.contains(.teardownTransport))
|
||
#expect(effects.contains(.scheduleReconnect(attempt: 1, delayMS: 500)))
|
||
}
|
||
|
||
@Test("重连计时→重连→成功")
|
||
func reconnectSuccess() {
|
||
var m = SessionMachine()
|
||
_ = m.reduce(.connectRequested)
|
||
_ = m.reduce(.established)
|
||
_ = m.reduce(.transportClosed(reason: nil))
|
||
#expect(m.reduce(.reconnectTimerFired) == [.startConnect])
|
||
#expect(m.phase == .reconnecting(attempt: 1))
|
||
#expect(m.reduce(.established) == [.notify("已重连")])
|
||
#expect(m.phase == .connected)
|
||
}
|
||
|
||
@Test("重连反复失败,退避递增,达上限转 failed")
|
||
func reconnectExhaustion() {
|
||
var m = SessionMachine(maxReconnectAttempts: 3, baseDelayMS: 500, maxDelayMS: 16000)
|
||
_ = m.reduce(.connectRequested)
|
||
_ = m.reduce(.established)
|
||
|
||
// 第1次断
|
||
_ = m.reduce(.transportClosed(reason: nil))
|
||
#expect(m.phase == .waitingToReconnect(attempt: 1))
|
||
_ = m.reduce(.reconnectTimerFired) // reconnecting(1)
|
||
// 第2次断(attempt 2, delay 1000)
|
||
let e2 = m.reduce(.transportClosed(reason: nil))
|
||
#expect(m.phase == .waitingToReconnect(attempt: 2))
|
||
#expect(e2.contains(.scheduleReconnect(attempt: 2, delayMS: 1000)))
|
||
_ = m.reduce(.reconnectTimerFired) // reconnecting(2)
|
||
// 第3次断(attempt 3, delay 2000)
|
||
let e3 = m.reduce(.transportClosed(reason: nil))
|
||
#expect(m.phase == .waitingToReconnect(attempt: 3))
|
||
#expect(e3.contains(.scheduleReconnect(attempt: 3, delayMS: 2000)))
|
||
_ = m.reduce(.reconnectTimerFired) // reconnecting(3)
|
||
// 第4次断 → 超过 maxReconnectAttempts(3) → failed
|
||
let e4 = m.reduce(.transportClosed(reason: "gone"))
|
||
if case .failed = m.phase {} else { Issue.record("期望 failed,实际 \(m.phase)") }
|
||
#expect(e4.contains(.notify("重连失败,请手动重试")))
|
||
}
|
||
|
||
@Test("退避封顶")
|
||
func backoffCap() {
|
||
let m = SessionMachine(baseDelayMS: 500, maxDelayMS: 16000)
|
||
#expect(m.backoffDelayMS(attempt: 1) == 500)
|
||
#expect(m.backoffDelayMS(attempt: 2) == 1000)
|
||
#expect(m.backoffDelayMS(attempt: 5) == 8000)
|
||
#expect(m.backoffDelayMS(attempt: 6) == 16000)
|
||
#expect(m.backoffDelayMS(attempt: 10) == 16000) // 封顶
|
||
}
|
||
|
||
@Test("后台冻结与前台恢复")
|
||
func backgroundResume() {
|
||
var m = SessionMachine()
|
||
_ = m.reduce(.connectRequested)
|
||
_ = m.reduce(.established)
|
||
#expect(m.reduce(.enteredBackground) == [])
|
||
#expect(m.phase == .backgroundParked)
|
||
let effects = m.reduce(.enteredForeground)
|
||
#expect(m.phase == .reconnecting(attempt: 1))
|
||
#expect(effects.contains(.startConnect))
|
||
}
|
||
|
||
@Test("等待重连时进入后台取消计时")
|
||
func backgroundDuringWait() {
|
||
var m = SessionMachine()
|
||
_ = m.reduce(.connectRequested)
|
||
_ = m.reduce(.established)
|
||
_ = m.reduce(.transportClosed(reason: nil))
|
||
#expect(m.phase == .waitingToReconnect(attempt: 1))
|
||
#expect(m.reduce(.enteredBackground) == [.cancelReconnectTimer])
|
||
#expect(m.phase == .backgroundParked)
|
||
}
|
||
|
||
@Test("主动关闭与手动重试复位")
|
||
func closeAndRetry() {
|
||
var m = SessionMachine()
|
||
_ = m.reduce(.connectRequested)
|
||
_ = m.reduce(.established)
|
||
let closeEffects = m.reduce(.closeRequested)
|
||
#expect(m.phase == .closed)
|
||
#expect(closeEffects.contains(.teardownTransport))
|
||
|
||
// failed 后手动重试
|
||
var m2 = SessionMachine()
|
||
_ = m2.reduce(.connectRequested)
|
||
_ = m2.reduce(.authFailed(reason: "x"))
|
||
#expect(m2.reduce(.retryRequested) == [.startConnect])
|
||
#expect(m2.phase == .connecting)
|
||
}
|
||
|
||
// MARK: - M3 mosh 生命周期
|
||
|
||
private func connectedThenMosh() -> SessionMachine {
|
||
var m = SessionMachine()
|
||
_ = m.reduce(.connectRequested)
|
||
_ = m.reduce(.established)
|
||
return m
|
||
}
|
||
|
||
@Test("mosh 接管拆 SSH,之后忽略 transportClosed")
|
||
func moshTakeover() {
|
||
var m = connectedThenMosh()
|
||
let e = m.reduce(.moshEstablished)
|
||
#expect(m.phase == .moshActive)
|
||
#expect(e.contains(.teardownTransport))
|
||
// 底层 SSH 残连断开,不影响 mosh
|
||
#expect(m.reduce(.transportClosed(reason: "ssh eof")) == [])
|
||
#expect(m.phase == .moshActive)
|
||
}
|
||
|
||
@Test("mosh 后台→前台:唤醒脉冲 + 看门狗,收到 healthy 即恢复")
|
||
func moshSuspendResumeHealthy() {
|
||
var m = connectedThenMosh()
|
||
_ = m.reduce(.moshEstablished)
|
||
#expect(m.reduce(.enteredBackground) == [])
|
||
#expect(m.phase == .moshParked)
|
||
let e = m.reduce(.enteredForeground)
|
||
#expect(m.phase == .moshResuming(attempt: 1))
|
||
#expect(e.contains(.nudgeResume))
|
||
#expect(e.contains(.scheduleResumeWatchdog(ms: 3000)))
|
||
// SSP 续上
|
||
let e2 = m.reduce(.moshHealthy)
|
||
#expect(m.phase == .moshActive)
|
||
#expect(e2.contains(.cancelResumeWatchdog))
|
||
}
|
||
|
||
@Test("恢复看门狗:一次超时再脉冲,两次仍失败则兜底全量重建")
|
||
func moshResumeWatchdogFallback() {
|
||
var m = SessionMachine(resumeWatchdogMS: 3000, maxResumePulses: 2)
|
||
_ = m.reduce(.connectRequested)
|
||
_ = m.reduce(.established)
|
||
_ = m.reduce(.moshEstablished)
|
||
_ = m.reduce(.enteredBackground)
|
||
_ = m.reduce(.enteredForeground) // moshResuming(1)
|
||
// 第一次看门狗到点 → 再脉冲,进 moshResuming(2)
|
||
let e1 = m.reduce(.resumeWatchdogFired)
|
||
#expect(m.phase == .moshResuming(attempt: 2))
|
||
#expect(e1.contains(.nudgeResume))
|
||
// 第二次看门狗到点 → 兜底全量重建
|
||
let e2 = m.reduce(.resumeWatchdogFired)
|
||
#expect(m.phase == .reconnecting(attempt: 1))
|
||
#expect(e2.contains(.teardownMosh))
|
||
#expect(e2.contains(.startConnect))
|
||
}
|
||
|
||
@Test("mosh 主循环退出转 failed 并拆除")
|
||
func moshExited() {
|
||
var m = connectedThenMosh()
|
||
_ = m.reduce(.moshEstablished)
|
||
let e = m.reduce(.moshExited(rc: 1))
|
||
if case .failed = m.phase {} else { Issue.record("期望 failed,实际 \(m.phase)") }
|
||
#expect(e.contains(.teardownMosh))
|
||
}
|
||
|
||
@Test("mosh 恢复中进后台取消看门狗")
|
||
func moshResumingToBackground() {
|
||
var m = connectedThenMosh()
|
||
_ = m.reduce(.moshEstablished)
|
||
_ = m.reduce(.enteredBackground)
|
||
_ = m.reduce(.enteredForeground) // moshResuming(1)
|
||
#expect(m.reduce(.enteredBackground) == [.cancelResumeWatchdog])
|
||
#expect(m.phase == .moshParked)
|
||
}
|
||
}
|