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:
19
apps/TerminalX/TerminalX-Info.plist
Normal file
19
apps/TerminalX/TerminalX-Info.plist
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- 部分 Info.plist 基底:仅声明 URL scheme(供无头验证前台唤醒 terminalx://resume)。
|
||||
其余键由 GENERATE_INFOPLIST_FILE=YES 自动生成并合并到本基底之上。 -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>ai.athom.terminalx</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>terminalx</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -3,6 +3,9 @@ import GhosttyTerminal
|
||||
import TXCore
|
||||
import TXTransport
|
||||
import TsnetBridge
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
enum TsnetError: Error { case newNodeFailed, notUp }
|
||||
|
||||
@@ -55,6 +58,12 @@ final class TsnetManager: @unchecked Sendable {
|
||||
guard let n else { throw TsnetError.notUp }
|
||||
return try n.startMoshRelay(host, moshPort: moshPort, timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
/// M3:前台恢复时唤醒 tsnet 网络层(注入链路事件 + Rebind/重新 STUN)。可能阻塞,后台线程调用。
|
||||
func wakeUp() {
|
||||
lock.lock(); let n = node; lock.unlock()
|
||||
n?.wakeUp()
|
||||
}
|
||||
}
|
||||
|
||||
/// 线程安全地持有 transport 引用,供 libghostty 的输入/resize 回调(非主线程)安全取用。
|
||||
@@ -133,8 +142,15 @@ final class SSHTerminalModel: ObservableObject {
|
||||
private let moshHolder: MoshHolder
|
||||
private var moshSession: MoshSession?
|
||||
private var moshRelay: TsnetbridgeMoshRelay?
|
||||
private let moshScanner = MoshConnectScanner()
|
||||
private var moshScanner = MoshConnectScanner()
|
||||
private var moshBootstrapSent = false
|
||||
// M3 前台恢复:基线时刻 + healthy 轮询 + 恢复看门狗 + 后台任务。
|
||||
private var moshResumeBaseMs: UInt64 = 0
|
||||
private var moshHealthyPoll: Task<Void, Never>?
|
||||
private var resumeWatchdog: Task<Void, Never>?
|
||||
#if canImport(UIKit)
|
||||
private var bgTask: UIBackgroundTaskIdentifier = .invalid
|
||||
#endif
|
||||
|
||||
init() {
|
||||
let holder = TransportHolder()
|
||||
@@ -168,8 +184,14 @@ final class SSHTerminalModel: ObservableObject {
|
||||
}
|
||||
|
||||
func close() { run(machine.reduce(.closeRequested)) }
|
||||
func enterBackground() { run(machine.reduce(.enteredBackground)) }
|
||||
func enterForeground() { run(machine.reduce(.enteredForeground)) }
|
||||
func enterBackground() {
|
||||
if moshMode { beginBgTask() } // 争取 ~30s 后台执行,mosh 每 3s ack 保活 NAT/WG/DERP
|
||||
run(machine.reduce(.enteredBackground))
|
||||
}
|
||||
func enterForeground() {
|
||||
endBgTask()
|
||||
run(machine.reduce(.enteredForeground))
|
||||
}
|
||||
|
||||
/// TerminalScreen 在 surface 首次布局后调用:放行缓冲输出(+ 无头自动命令)。
|
||||
func markSurfaceReady() {
|
||||
@@ -213,6 +235,10 @@ final class SSHTerminalModel: ObservableObject {
|
||||
case .cancelReconnectTimer: reconnectTask?.cancel(); reconnectTask = nil
|
||||
case .teardownTransport: holder.transport?.stop()
|
||||
case .notify(let message): banner = message
|
||||
case .nudgeResume: performWakePulse(); startHealthyPollIfNeeded()
|
||||
case .scheduleResumeWatchdog(let ms): scheduleResumeWatchdog(ms)
|
||||
case .cancelResumeWatchdog: cancelResumeWatchdog()
|
||||
case .teardownMosh: teardownMosh()
|
||||
}
|
||||
}
|
||||
syncUI()
|
||||
@@ -343,14 +369,93 @@ final class SSHTerminalModel: ObservableObject {
|
||||
self.gate.deliver(Data(bytes))
|
||||
}
|
||||
mosh.onClosed = { [weak self] rc in
|
||||
Task { @MainActor in self?.banner = "mosh 已结束(rc=\(rc))" }
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.run(self.machine.reduce(.moshExited(rc: Int(rc))))
|
||||
}
|
||||
}
|
||||
moshRelay = relay
|
||||
moshSession = mosh
|
||||
moshHolder.session = mosh // 输入/resize 从此路由到 mosh
|
||||
mosh.start()
|
||||
NSLog("MOSHDBG activateMosh ip=\(ip) port=\(port) started")
|
||||
banner = "mosh 已连接"
|
||||
// mosh 接管成功 → 状态机拆掉底层 idle SSH(moshActive 之后忽略其 transportClosed)。
|
||||
run(machine.reduce(.moshEstablished))
|
||||
}
|
||||
|
||||
// MARK: - M3 前台快速恢复
|
||||
|
||||
/// 唤醒脉冲:tsnet WakeUp(后台线程,可能阻塞)+ relay 同端口 Rebind + mosh SIGCONT。
|
||||
private func performWakePulse() {
|
||||
guard let mosh = moshSession else { return }
|
||||
if moshResumeBaseMs == 0 { moshResumeBaseMs = mosh.lastHeardMs() }
|
||||
if tsnetAuthKey != nil {
|
||||
let mgr = tsnetMgr
|
||||
Task.detached { mgr.wakeUp() }
|
||||
}
|
||||
do { try moshRelay?.rebind() }
|
||||
catch { NSLog("MOSHDBG relay rebind failed: \(error.localizedDescription)") }
|
||||
mosh.nudge()
|
||||
NSLog("MOSHDBG wake pulse base=\(moshResumeBaseMs)")
|
||||
}
|
||||
|
||||
/// 轮询 mosh 探针:收到基线之后的新服务器状态即判定 SSP 已续(healthy)。
|
||||
private func startHealthyPollIfNeeded() {
|
||||
guard moshHealthyPoll == nil, let mosh = moshSession else { return }
|
||||
let base = moshResumeBaseMs
|
||||
moshHealthyPoll = Task { [weak self] in
|
||||
for _ in 0 ..< 100 { // ~10s 安全上限(看门狗更早兜底)
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
if Task.isCancelled { return }
|
||||
if mosh.lastHeardMs() > base {
|
||||
guard let self else { return }
|
||||
NSLog("TXM3 recovered base=\(base) now=\(mosh.lastHeardMs())")
|
||||
self.moshHealthyPoll = nil
|
||||
self.run(self.machine.reduce(.moshHealthy))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleResumeWatchdog(_ ms: Int) {
|
||||
resumeWatchdog?.cancel()
|
||||
resumeWatchdog = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(ms) * 1_000_000)
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.run(self.machine.reduce(.resumeWatchdogFired))
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelResumeWatchdog() {
|
||||
resumeWatchdog?.cancel(); resumeWatchdog = nil
|
||||
moshHealthyPoll?.cancel(); moshHealthyPoll = nil
|
||||
moshResumeBaseMs = 0
|
||||
}
|
||||
|
||||
/// 拆除 mosh 会话(兜底全量重建 / 关闭时)。重置引导状态,允许重新引导 mosh-server。
|
||||
private func teardownMosh() {
|
||||
cancelResumeWatchdog()
|
||||
moshHolder.session = nil
|
||||
moshSession?.close(); moshSession = nil
|
||||
try? moshRelay?.close(); moshRelay = nil
|
||||
moshBootstrapSent = false
|
||||
moshScanner = MoshConnectScanner()
|
||||
}
|
||||
|
||||
private func beginBgTask() {
|
||||
#if canImport(UIKit)
|
||||
guard bgTask == .invalid else { return }
|
||||
bgTask = UIApplication.shared.beginBackgroundTask(withName: "mosh-keepalive") { [weak self] in
|
||||
self?.endBgTask()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func endBgTask() {
|
||||
#if canImport(UIKit)
|
||||
if bgTask != .invalid { UIApplication.shared.endBackgroundTask(bgTask); bgTask = .invalid }
|
||||
#endif
|
||||
}
|
||||
|
||||
private func handleTransportState(_ st: TransportState) {
|
||||
@@ -379,7 +484,8 @@ final class SSHTerminalModel: ObservableObject {
|
||||
|
||||
private func syncUI() {
|
||||
switch machine.phase {
|
||||
case .connected, .reconnecting, .waitingToReconnect, .backgroundParked:
|
||||
case .connected, .reconnecting, .waitingToReconnect, .backgroundParked,
|
||||
.moshActive, .moshParked, .moshResuming:
|
||||
showsTerminal = true
|
||||
case .idle, .connecting, .authenticating, .closed, .failed:
|
||||
showsTerminal = false
|
||||
@@ -398,6 +504,9 @@ final class SSHTerminalModel: ObservableObject {
|
||||
case .reconnecting(let n): "重连中(第 \(n) 次)"
|
||||
case .failed(let r): "失败:\(r)"
|
||||
case .closed: "已关闭"
|
||||
case .moshActive: "mosh 已连接"
|
||||
case .moshParked: "已挂起(后台)"
|
||||
case .moshResuming(let n): "mosh 恢复中(第 \(n) 次)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ targets:
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
SWIFT_VERSION: "6.0"
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
# 部分 Info.plist 基底(仅 URL scheme,供无头验证前台唤醒);生成键合并其上。
|
||||
INFOPLIST_FILE: TerminalX-Info.plist
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||
|
||||
Reference in New Issue
Block a user