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:
kid
2026-07-24 17:04:33 +08:00
parent 32520f09e0
commit 866df1a323
23 changed files with 489 additions and 96 deletions

View File

@@ -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:

View File

@@ -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)
}
}

View File

@@ -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 */

View File

@@ -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) }
}
/// M3mosh mosh ms0=
/// 线 SSP healthy
public func lastHeardMs() -> UInt64 { UInt64(mosh_last_heard_ms()) }
/// moshSIGWINCH process_resize
public func resize(cols: Int, rows: Int) {
winPtr.pointee.ws_col = UInt16(cols)