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>
This commit is contained in:
@@ -22,6 +22,13 @@ public struct SessionMachine: Sendable, Equatable {
|
||||
case failed(reason: String)
|
||||
/// 用户主动关闭。
|
||||
case closed
|
||||
// —— M3 mosh 会话相(mosh 接管后,SSH 已拆,生命周期由 mosh SSP 承载)——
|
||||
/// mosh 已接管并活跃(底层 SSH 已拆除;transportClosed 不再影响)。
|
||||
case moshActive
|
||||
/// 进入后台,mosh 冻结(SSP 会在前台自动续传,不重建)。
|
||||
case moshParked
|
||||
/// 前台恢复中:已发唤醒脉冲,等 mosh SSP resync(记录第几次尝试,超时兜底)。
|
||||
case moshResuming(attempt: Int)
|
||||
}
|
||||
|
||||
public enum Event: Sendable, Equatable {
|
||||
@@ -35,6 +42,11 @@ public struct SessionMachine: Sendable, Equatable {
|
||||
case enteredForeground
|
||||
case closeRequested
|
||||
case retryRequested // 用户手动重试(从 failed 复位)
|
||||
// —— M3 mosh ——
|
||||
case moshEstablished // mosh 接管会话成功(此后拆 SSH)
|
||||
case moshHealthy // 前台恢复后收到服务器合法包(SSP 已续上)
|
||||
case moshExited(rc: Int) // mosh 主循环退出(会话结束)
|
||||
case resumeWatchdogFired // 恢复看门狗到点(本次唤醒脉冲未见 healthy)
|
||||
}
|
||||
|
||||
public enum Effect: Sendable, Equatable {
|
||||
@@ -43,6 +55,11 @@ public struct SessionMachine: Sendable, Equatable {
|
||||
case cancelReconnectTimer
|
||||
case teardownTransport
|
||||
case notify(String) // UI 提示
|
||||
// —— M3 mosh ——
|
||||
case nudgeResume // 打唤醒脉冲:tsnet WakeUp + relay Rebind + mosh SIGCONT
|
||||
case scheduleResumeWatchdog(ms: Int) // 排程恢复看门狗
|
||||
case cancelResumeWatchdog
|
||||
case teardownMosh // 拆除 mosh 会话(relay + 线程)
|
||||
}
|
||||
|
||||
public private(set) var phase: Phase = .idle
|
||||
@@ -52,11 +69,18 @@ public struct SessionMachine: Sendable, Equatable {
|
||||
/// 退避基数与上限(毫秒)。
|
||||
private let baseDelayMS: Int
|
||||
private let maxDelayMS: Int
|
||||
/// M3:单次唤醒脉冲后等 mosh SSP 续上的看门狗窗口;超时则再脉冲一次。
|
||||
public let resumeWatchdogMS: Int
|
||||
/// M3:最多打几次唤醒脉冲,仍未 healthy 则兜底全量重建。
|
||||
public let maxResumePulses: Int
|
||||
|
||||
public init(maxReconnectAttempts: Int = 6, baseDelayMS: Int = 500, maxDelayMS: Int = 16000) {
|
||||
public init(maxReconnectAttempts: Int = 6, baseDelayMS: Int = 500, maxDelayMS: Int = 16000,
|
||||
resumeWatchdogMS: Int = 3000, maxResumePulses: Int = 2) {
|
||||
self.maxReconnectAttempts = maxReconnectAttempts
|
||||
self.baseDelayMS = baseDelayMS
|
||||
self.maxDelayMS = maxDelayMS
|
||||
self.resumeWatchdogMS = resumeWatchdogMS
|
||||
self.maxResumePulses = maxResumePulses
|
||||
}
|
||||
|
||||
/// 指数退避:base * 2^(attempt-1),封顶 maxDelayMS。
|
||||
@@ -135,10 +159,60 @@ public struct SessionMachine: Sendable, Equatable {
|
||||
phase = .backgroundParked
|
||||
return [.cancelReconnectTimer]
|
||||
|
||||
// —— M3 mosh 会话相 ——
|
||||
// mosh 接管成功(引导完成):拆掉底层 SSH,进入 mosh 自持会话。
|
||||
case (.connected, .moshEstablished), (.reconnecting, .moshEstablished):
|
||||
phase = .moshActive
|
||||
return [.teardownTransport, .notify("mosh 已接管")]
|
||||
|
||||
// mosh 活跃期间底层 SSH 残连断开:忽略(mosh 不依赖 SSH)。
|
||||
case (.moshActive, .transportClosed):
|
||||
return []
|
||||
|
||||
// mosh 活跃 → 后台:冻结,不重建。
|
||||
case (.moshActive, .enteredBackground):
|
||||
phase = .moshParked
|
||||
return []
|
||||
|
||||
// mosh 后台 → 前台:打唤醒脉冲 + 排看门狗,等 SSP 续上。
|
||||
case (.moshParked, .enteredForeground):
|
||||
phase = .moshResuming(attempt: 1)
|
||||
return [.nudgeResume, .scheduleResumeWatchdog(ms: resumeWatchdogMS), .notify("恢复中…")]
|
||||
|
||||
// mosh 后台期间的杂项(底层断/重复进后台):保持 parked。
|
||||
case (.moshParked, .transportClosed), (.moshParked, .enteredBackground):
|
||||
return []
|
||||
|
||||
// 恢复中收到服务器合法包(SSP 已续):回到活跃。
|
||||
case (.moshResuming, .moshHealthy):
|
||||
phase = .moshActive
|
||||
return [.cancelResumeWatchdog, .notify("已恢复")]
|
||||
|
||||
// 恢复看门狗到点:未 healthy → 再脉冲;达上限 → 兜底全量重建。
|
||||
case (.moshResuming(let attempt), .resumeWatchdogFired):
|
||||
if attempt < maxResumePulses {
|
||||
phase = .moshResuming(attempt: attempt + 1)
|
||||
return [.nudgeResume, .scheduleResumeWatchdog(ms: resumeWatchdogMS)]
|
||||
}
|
||||
phase = .reconnecting(attempt: 1)
|
||||
return [.teardownMosh, .startConnect, .notify("mosh 恢复失败,重建会话…")]
|
||||
|
||||
// 恢复中又进后台:取消看门狗,回 parked。
|
||||
case (.moshResuming, .enteredBackground):
|
||||
phase = .moshParked
|
||||
return [.cancelResumeWatchdog]
|
||||
|
||||
// mosh 主循环退出(会话结束):终态失败,可手动重试。
|
||||
case (.moshActive, .moshExited(let rc)),
|
||||
(.moshResuming, .moshExited(let rc)),
|
||||
(.moshParked, .moshExited(let rc)):
|
||||
phase = .failed(reason: "mosh 已结束(rc=\(rc))")
|
||||
return [.cancelResumeWatchdog, .teardownMosh, .notify("mosh 会话结束")]
|
||||
|
||||
// —— 关闭 ——
|
||||
case (_, .closeRequested):
|
||||
phase = .closed
|
||||
return [.cancelReconnectTimer, .teardownTransport]
|
||||
return [.cancelReconnectTimer, .cancelResumeWatchdog, .teardownTransport, .teardownMosh]
|
||||
|
||||
// 其它组合不改变状态
|
||||
default:
|
||||
|
||||
@@ -125,4 +125,78 @@ struct SessionMachineTests {
|
||||
#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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,10 @@ extern "C"
|
||||
int mosh_main(FILE *f_in, FILE *f_out, struct winsize *window_size,
|
||||
const char *ip, const char *port, const char *key, const char *predict_mode);
|
||||
|
||||
/* M3:最近一次收到新服务器状态的 mosh 单调时刻(ms);0=从未。前台恢复判定用。 */
|
||||
#if __cplusplus
|
||||
extern "C"
|
||||
#endif
|
||||
unsigned long long mosh_last_heard_ms(void);
|
||||
|
||||
#endif /* CMOSH_MOSHIOSBRIDGE_H */
|
||||
|
||||
@@ -86,6 +86,16 @@ public final class MoshSession: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// M3:唤醒 mosh 主循环(前台恢复)——发 SIGCONT,触发 select 立即 tick、发包、全屏重绘。
|
||||
public func nudge() {
|
||||
lock.lock(); let tid = moshThread; lock.unlock()
|
||||
if let tid { pthread_kill(tid, SIGCONT) }
|
||||
}
|
||||
|
||||
/// M3:mosh 最近收到新(已认证)服务器状态的时刻(mosh 单调钟 ms);0=从未。
|
||||
/// 前台恢复时取基线、轮询其增长即判定 SSP 已续(healthy)。
|
||||
public func lastHeardMs() -> UInt64 { UInt64(mosh_last_heard_ms()) }
|
||||
|
||||
/// 更新终端尺寸并通知 mosh(SIGWINCH → process_resize)。
|
||||
public func resize(cols: Int, rows: Int) {
|
||||
winPtr.pointee.ws_col = UInt16(cols)
|
||||
|
||||
Reference in New Issue
Block a user