Files
terminalX/packages/TXCore/Tests/TXCoreTests/SessionMachineTests.swift
kid 866df1a323 feat: M3 mosh 挂起→恢复<3s(唤醒脉冲 + 会话状态机三相)
- 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>
2026-07-24 17:04:33 +08:00

203 lines
8.0 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
// 2attempt 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)
// 3attempt 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)
}
}