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

@@ -59,6 +59,8 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具
- **mosh over tsnet**:额外加 `-txTsnetKey <authkey>`(-txHost 填 tailnet IP;relay=loopback↔tsnet UDP) - **mosh over tsnet**:额外加 `-txTsnetKey <authkey>`(-txHost 填 tailnet IP;relay=loopback↔tsnet UDP)
- 自定义 mosh-server 命令:`-txMoshServerCmd "…"`(默认 `mosh-server new -s -c 256 -l LANG=en_US.UTF-8 -l LC_ALL=en_US.UTF-8`) - 自定义 mosh-server 命令:`-txMoshServerCmd "…"`(默认 `mosh-server new -s -c 256 -l LANG=en_US.UTF-8 -l LC_ALL=en_US.UTF-8`)
**M3 挂起→恢复无头验证**:退后台 `simctl openurl $UDID https://apple.com`;冻结 `kill -STOP $(pgrep -x TerminalX)`;解冻 `kill -CONT`;前台 `simctl openurl $UDID "terminalx://resume"`(URL scheme 已注册)。日志 `log stream | grep -E 'MOSHDBG|TXM3'` 看 `wake pulse`→`TXM3 recovered` 时间差判 <3s。**坑:模拟器看门狗会杀掉被 STOP 冻结过久(>~30s)的 app;且模拟器不复现 socket defunct/WG 过期,深挂起需真机锁屏补测。**
## 仓库结构 ## 仓库结构
- `packages/TXCore` — 零依赖纯逻辑:tmux 解析器/layout/`SessionMachine`(会话状态机+退避重连)/`TmuxControlSequence`(DCS 检测)。 - `packages/TXCore` — 零依赖纯逻辑:tmux 解析器/layout/`SessionMachine`(会话状态机+退避重连)/`TmuxControlSequence`(DCS 检测)。
- `packages/TXTransport` — `SSHSession`(libssh2, 支持外部 fd)/`MoshSession`(mosh_main 桥)/`Transport` 协议;C shim: `CSSH`+`CSSHCore`(libssh2)、`CMosh`+`MoshCore`(mosh)。 - `packages/TXTransport` — `SSHSession`(libssh2, 支持外部 fd)/`MoshSession`(mosh_main 桥)/`Transport` 协议;C shim: `CSSH`+`CSSHCore`(libssh2)、`CMosh`+`MoshCore`(mosh)。

View 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>

View File

@@ -3,6 +3,9 @@ import GhosttyTerminal
import TXCore import TXCore
import TXTransport import TXTransport
import TsnetBridge import TsnetBridge
#if canImport(UIKit)
import UIKit
#endif
enum TsnetError: Error { case newNodeFailed, notUp } enum TsnetError: Error { case newNodeFailed, notUp }
@@ -55,6 +58,12 @@ final class TsnetManager: @unchecked Sendable {
guard let n else { throw TsnetError.notUp } guard let n else { throw TsnetError.notUp }
return try n.startMoshRelay(host, moshPort: moshPort, timeoutMs: timeoutMs) 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 回调(非主线程)安全取用。 /// 线程安全地持有 transport 引用,供 libghostty 的输入/resize 回调(非主线程)安全取用。
@@ -133,8 +142,15 @@ final class SSHTerminalModel: ObservableObject {
private let moshHolder: MoshHolder private let moshHolder: MoshHolder
private var moshSession: MoshSession? private var moshSession: MoshSession?
private var moshRelay: TsnetbridgeMoshRelay? private var moshRelay: TsnetbridgeMoshRelay?
private let moshScanner = MoshConnectScanner() private var moshScanner = MoshConnectScanner()
private var moshBootstrapSent = false 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() { init() {
let holder = TransportHolder() let holder = TransportHolder()
@@ -168,8 +184,14 @@ final class SSHTerminalModel: ObservableObject {
} }
func close() { run(machine.reduce(.closeRequested)) } func close() { run(machine.reduce(.closeRequested)) }
func enterBackground() { run(machine.reduce(.enteredBackground)) } func enterBackground() {
func enterForeground() { run(machine.reduce(.enteredForeground)) } if moshMode { beginBgTask() } // 争取 ~30s 后台执行,mosh 每 3s ack 保活 NAT/WG/DERP
run(machine.reduce(.enteredBackground))
}
func enterForeground() {
endBgTask()
run(machine.reduce(.enteredForeground))
}
/// TerminalScreen 在 surface 首次布局后调用:放行缓冲输出(+ 无头自动命令)。 /// TerminalScreen 在 surface 首次布局后调用:放行缓冲输出(+ 无头自动命令)。
func markSurfaceReady() { func markSurfaceReady() {
@@ -213,6 +235,10 @@ final class SSHTerminalModel: ObservableObject {
case .cancelReconnectTimer: reconnectTask?.cancel(); reconnectTask = nil case .cancelReconnectTimer: reconnectTask?.cancel(); reconnectTask = nil
case .teardownTransport: holder.transport?.stop() case .teardownTransport: holder.transport?.stop()
case .notify(let message): banner = message case .notify(let message): banner = message
case .nudgeResume: performWakePulse(); startHealthyPollIfNeeded()
case .scheduleResumeWatchdog(let ms): scheduleResumeWatchdog(ms)
case .cancelResumeWatchdog: cancelResumeWatchdog()
case .teardownMosh: teardownMosh()
} }
} }
syncUI() syncUI()
@@ -343,14 +369,93 @@ final class SSHTerminalModel: ObservableObject {
self.gate.deliver(Data(bytes)) self.gate.deliver(Data(bytes))
} }
mosh.onClosed = { [weak self] rc in 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 moshRelay = relay
moshSession = mosh moshSession = mosh
moshHolder.session = mosh // 输入/resize 从此路由到 mosh moshHolder.session = mosh // 输入/resize 从此路由到 mosh
mosh.start() mosh.start()
NSLog("MOSHDBG activateMosh ip=\(ip) port=\(port) started") 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) { private func handleTransportState(_ st: TransportState) {
@@ -379,7 +484,8 @@ final class SSHTerminalModel: ObservableObject {
private func syncUI() { private func syncUI() {
switch machine.phase { switch machine.phase {
case .connected, .reconnecting, .waitingToReconnect, .backgroundParked: case .connected, .reconnecting, .waitingToReconnect, .backgroundParked,
.moshActive, .moshParked, .moshResuming:
showsTerminal = true showsTerminal = true
case .idle, .connecting, .authenticating, .closed, .failed: case .idle, .connecting, .authenticating, .closed, .failed:
showsTerminal = false showsTerminal = false
@@ -398,6 +504,9 @@ final class SSHTerminalModel: ObservableObject {
case .reconnecting(let n): "重连中(第 \(n) 次)" case .reconnecting(let n): "重连中(第 \(n) 次)"
case .failed(let r): "失败:\(r)" case .failed(let r): "失败:\(r)"
case .closed: "已关闭" case .closed: "已关闭"
case .moshActive: "mosh 已连接"
case .moshParked: "已挂起(后台)"
case .moshResuming(let n): "mosh 恢复中(第 \(n) 次)"
} }
} }
} }

View File

@@ -40,6 +40,8 @@ targets:
CURRENT_PROJECT_VERSION: "1" CURRENT_PROJECT_VERSION: "1"
SWIFT_VERSION: "6.0" SWIFT_VERSION: "6.0"
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
# 部分 Info.plist 基底(仅 URL scheme,供无头验证前台唤醒);生成键合并其上。
INFOPLIST_FILE: TerminalX-Info.plist
TARGETED_DEVICE_FAMILY: "1,2" TARGETED_DEVICE_FAMILY: "1,2"
INFOPLIST_KEY_UILaunchScreen_Generation: YES INFOPLIST_KEY_UILaunchScreen_Generation: YES
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES

View File

@@ -21,20 +21,6 @@
<key>SupportedPlatformVariant</key> <key>SupportedPlatformVariant</key>
<string>simulator</string> <string>simulator</string>
</dict> </dict>
<dict>
<key>BinaryPath</key>
<string>libMoshCore.a</string>
<key>LibraryIdentifier</key>
<string>ios-arm64</string>
<key>LibraryPath</key>
<string>libMoshCore.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
</dict>
<dict> <dict>
<key>BinaryPath</key> <key>BinaryPath</key>
<string>libMoshCore.a</string> <string>libMoshCore.a</string>
@@ -49,6 +35,20 @@
<key>SupportedPlatform</key> <key>SupportedPlatform</key>
<string>macos</string> <string>macos</string>
</dict> </dict>
<dict>
<key>BinaryPath</key>
<string>libMoshCore.a</string>
<key>LibraryIdentifier</key>
<string>ios-arm64</string>
<key>LibraryPath</key>
<string>libMoshCore.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
</dict>
</array> </array>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>XFWK</string> <string>XFWK</string>

View File

@@ -4,6 +4,20 @@
<dict> <dict>
<key>AvailableLibraries</key> <key>AvailableLibraries</key>
<array> <array>
<dict>
<key>BinaryPath</key>
<string>TsnetBridge.framework/TsnetBridge</string>
<key>LibraryIdentifier</key>
<string>ios-arm64</string>
<key>LibraryPath</key>
<string>TsnetBridge.framework</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
</dict>
<dict> <dict>
<key>BinaryPath</key> <key>BinaryPath</key>
<string>TsnetBridge.framework/TsnetBridge</string> <string>TsnetBridge.framework/TsnetBridge</string>
@@ -21,20 +35,6 @@
<key>SupportedPlatformVariant</key> <key>SupportedPlatformVariant</key>
<string>simulator</string> <string>simulator</string>
</dict> </dict>
<dict>
<key>BinaryPath</key>
<string>TsnetBridge.framework/TsnetBridge</string>
<key>LibraryIdentifier</key>
<string>ios-arm64</string>
<key>LibraryPath</key>
<string>TsnetBridge.framework</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
</dict>
</array> </array>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>XFWK</string> <string>XFWK</string>

View File

@@ -27,13 +27,18 @@ tailnet 内 host:moshPort,回包再转回"最近一次上行的 client 源地
- (nonnull instancetype)initWithRef:(_Nonnull id)ref; - (nonnull instancetype)initWithRef:(_Nonnull id)ref;
- (nonnull instancetype)init; - (nonnull instancetype)init;
/** /**
* Close 关闭中继(两条泵 goroutine 随即因 conn 关闭而退出)。 * Close 关闭中继(两条泵随 conn 关闭而退出)。
*/ */
- (BOOL)close:(NSError* _Nullable* _Nullable)error; - (BOOL)close:(NSError* _Nullable* _Nullable)error;
/** /**
* LocalPort 返回 loopback relay 监听的端口(mosh_main 的 port 参数)。 * LocalPort 返回固定的 loopback relay 端口(mosh_main 的 port 参数,跨 Rebind 不变)。
*/ */
- (long)localPort; - (long)localPort;
/**
* Rebind 在同端口重开 loopback socket(iOS 前台恢复用:挂起后旧 socket 可能被系统回收为 defunct)。
关旧 socket → 旧上行泵退出 → 新泵接管;下行泵绑定稳定 tsConn 无需重启。端口被占等返回 error(由上层升级为全量重建)。
*/
- (BOOL)rebind:(NSError* _Nullable* _Nullable)error;
@end @end
/** /**
@@ -77,6 +82,11 @@ Swift 侧 close(fd) 会触发泵结束并关闭 tsConn。返回 -1 + error 表
gomobile 友好签名:基本类型入参、error 出参。 gomobile 友好签名:基本类型入参、error 出参。
*/ */
- (BOOL)upWithAuthKey:(NSString* _Nullable)authKey timeoutMs:(long)timeoutMs error:(NSError* _Nullable* _Nullable)error; - (BOOL)upWithAuthKey:(NSString* _Nullable)authKey timeoutMs:(long)timeoutMs error:(NSError* _Nullable* _Nullable)error;
/**
* WakeUp 在 iOS 前台恢复时主动唤醒 tsnet 网络层:注入链路变化事件 + Rebind/重新 STUN,
把"等首次写失败才发现 DERP/路径已死"变成"立刻重建",配合 mosh SSP 实现秒级恢复。
*/
- (void)wakeUp;
@end @end
/** /**

View File

@@ -9,9 +9,9 @@
<key>MinimumOSVersion</key> <key>MinimumOSVersion</key>
<string>100.0</string> <string>100.0</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.0.1784861278</string> <string>0.0.1784882284</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>0.0.1784861278</string> <string>0.0.1784882284</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>FMWK</string> <string>FMWK</string>
</dict> </dict>

View File

@@ -27,13 +27,18 @@ tailnet 内 host:moshPort,回包再转回"最近一次上行的 client 源地
- (nonnull instancetype)initWithRef:(_Nonnull id)ref; - (nonnull instancetype)initWithRef:(_Nonnull id)ref;
- (nonnull instancetype)init; - (nonnull instancetype)init;
/** /**
* Close 关闭中继(两条泵 goroutine 随即因 conn 关闭而退出)。 * Close 关闭中继(两条泵随 conn 关闭而退出)。
*/ */
- (BOOL)close:(NSError* _Nullable* _Nullable)error; - (BOOL)close:(NSError* _Nullable* _Nullable)error;
/** /**
* LocalPort 返回 loopback relay 监听的端口(mosh_main 的 port 参数)。 * LocalPort 返回固定的 loopback relay 端口(mosh_main 的 port 参数,跨 Rebind 不变)。
*/ */
- (long)localPort; - (long)localPort;
/**
* Rebind 在同端口重开 loopback socket(iOS 前台恢复用:挂起后旧 socket 可能被系统回收为 defunct)。
关旧 socket → 旧上行泵退出 → 新泵接管;下行泵绑定稳定 tsConn 无需重启。端口被占等返回 error(由上层升级为全量重建)。
*/
- (BOOL)rebind:(NSError* _Nullable* _Nullable)error;
@end @end
/** /**
@@ -77,6 +82,11 @@ Swift 侧 close(fd) 会触发泵结束并关闭 tsConn。返回 -1 + error 表
gomobile 友好签名:基本类型入参、error 出参。 gomobile 友好签名:基本类型入参、error 出参。
*/ */
- (BOOL)upWithAuthKey:(NSString* _Nullable)authKey timeoutMs:(long)timeoutMs error:(NSError* _Nullable* _Nullable)error; - (BOOL)upWithAuthKey:(NSString* _Nullable)authKey timeoutMs:(long)timeoutMs error:(NSError* _Nullable* _Nullable)error;
/**
* WakeUp 在 iOS 前台恢复时主动唤醒 tsnet 网络层:注入链路变化事件 + Rebind/重新 STUN,
把"等首次写失败才发现 DERP/路径已死"变成"立刻重建",配合 mosh SSP 实现秒级恢复。
*/
- (void)wakeUp;
@end @end
/** /**

View File

@@ -9,9 +9,9 @@
<key>MinimumOSVersion</key> <key>MinimumOSVersion</key>
<string>100.0</string> <string>100.0</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.0.1784861278</string> <string>0.0.1784882284</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>0.0.1784861278</string> <string>0.0.1784882284</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>FMWK</string> <string>FMWK</string>
</dict> </dict>

View File

@@ -6,7 +6,7 @@
--- ---
## 0. 一句话现状 ## 0. 一句话现状
一个**原生 iPad SSH 终端**已可用并真机验证:libghostty 渲染 + 键盘输入 + SSH 直连 + **SSH-over-tsnet(Tailscale 用户态)** + **tmux -CC 原生 tab** + **mosh(LAN 直连已端到端验证)** + 断线自动重连。四大核心需求(libghostty/tailscale/tmux/**mosh 均已验证**)。仅在 iOS 模拟器验证(未上真机/未签名/未提交 git)。 一个**原生 iPad SSH 终端**已可用并真机验证:libghostty 渲染 + 键盘输入 + SSH 直连 + **SSH-over-tsnet(Tailscale 用户态)** + **tmux -CC 原生 tab** + **mosh(LAN + over-tsnet 均已端到端验证)** + 断线自动重连 + **mosh 挂起→恢复<3s(M3)**。四大核心需求(libghostty/tailscale/tmux/**mosh 均已验证**)。仅在 iOS 模拟器验证(未上真机/未签名)。
> **mosh 状态(2026-07-24 全部验证通过)**:mosh(C++,blinksh/ios)+protobuf-lite 交叉编译成 `MoshCore.xcframework`(CommonCrypto 后端,含 arm64 模拟器 slice);tsnet 桥加 UDP relay(`StartMoshRelay`);`MoshSession`(pipe↔FILE*/pthread/SIGWINCH)+`MoshConnectScanner`(TXCore,39 单测)+`SSHTerminalModel` 编排(SSH 引导 mosh-server→解析 MOSH CONNECT→接管)。 > **mosh 状态(2026-07-24 全部验证通过)**:mosh(C++,blinksh/ios)+protobuf-lite 交叉编译成 `MoshCore.xcframework`(CommonCrypto 后端,含 arm64 模拟器 slice);tsnet 桥加 UDP relay(`StartMoshRelay`);`MoshSession`(pipe↔FILE*/pthread/SIGWINCH)+`MoshConnectScanner`(TXCore,39 单测)+`SSHTerminalModel` 编排(SSH 引导 mosh-server→解析 MOSH CONNECT→接管)。
> - **LAN 直连端到端验证**(192.168.9.199):mosh-server 存活 104s≫60s 无客户端超时+子 shell+心跳=真连。 > - **LAN 直连端到端验证**(192.168.9.199):mosh-server 存活 104s≫60s 无客户端超时+子 shell+心跳=真连。
> - **mosh over tsnet 端到端验证**(vohive-vm 100.69.201.101):app 日志 `parsed MOSH CONNECT port=60002`→`relay up localPort=63542`→`activateMosh started`;屏幕渲染远端 shell 且无 mosh 断连 overlay=relay UDP 双向流通。**R1(tsnet UDP 数据报语义) 证伪**。 > - **mosh over tsnet 端到端验证**(vohive-vm 100.69.201.101):app 日志 `parsed MOSH CONNECT port=60002`→`relay up localPort=63542`→`activateMosh started`;屏幕渲染远端 shell 且无 mosh 断连 overlay=relay UDP 双向流通。**R1(tsnet UDP 数据报语义) 证伪**。
@@ -41,20 +41,21 @@
| dial 重试打磨 | ✅ | `TsnetManager.dialFD` 6 次退避重试,解决 tsnet 首连路径预热超时 | | dial 重试打磨 | ✅ | `TsnetManager.dialFD` 6 次退避重试,解决 tsnet 首连路径预热超时 |
| **R5** Go+gvisor+C+++Swift 同进程 | ✅ retire | 全程真机无崩溃 | | **R5** Go+gvisor+C+++Swift 同进程 | ✅ retire | 全程真机无崩溃 |
| **M2** mosh | ✅ 完成(LAN + tsnet 均验证) | libmoshios(C++)+protobuf-lite→`MoshCore.xcframework`;UDP relay over tsnet;**LAN 直连 + mosh-over-tsnet 均端到端验证通过**(R1 证伪) | | **M2** mosh | ✅ 完成(LAN + tsnet 均验证) | libmoshios(C++)+protobuf-lite→`MoshCore.xcframework`;UDP relay over tsnet;**LAN 直连 + mosh-over-tsnet 均端到端验证通过**(R1 证伪) |
| **M3** 生命周期深化 | ◻️ 部分 | 前后台 FSM 已做;mosh 快速恢复 + tmux 兜底 待做 | | **M3** 生命周期深化 | ✅ mosh 快恢复(真机深挂起待补) | scenePhase FSM + **mosh 挂起→恢复<3s 已验证**(模拟器:15/30s 冻结→前台,wake pulse→1.3s 收到服务器包,"已恢复");冷启动重连(SSP 序号+key 持久化)划入 M4/M5 |
| **M4** 安全 | ◻️ 未做 | **当前 SSH 接受任意 host key**(见 SSHSession TODO);known_hosts 固定 + Secure Enclave 密钥 待做 | | **M4** 安全 | ◻️ 未做 | **当前 SSH 接受任意 host key**(见 SSHSession TODO);known_hosts 固定 + Secure Enclave 密钥 待做 |
| **M5** 合规提审 | ⏸️ 暂缓 | 用户指示暂缓 | | **M5** 合规提审 | ⏸️ 暂缓 | 用户指示暂缓 |
## 5. 代码地图与数据流 ## 5. 代码地图与数据流
- `packages/TXCore/Sources/TXCore/` - `packages/TXCore/Sources/TXCore/`
- `Tmux/TmuxControlParser.swift` `TmuxEvent.swift` `TmuxIDs.swift` `TmuxOutputDecoder.swift` `TmuxLayout.swift` `TmuxControlSequence.swift`(DCS 检测)—— 纯解析,35 单测在 `Tests/`。 - `Tmux/TmuxControlParser.swift` `TmuxEvent.swift` `TmuxIDs.swift` `TmuxOutputDecoder.swift` `TmuxLayout.swift` `TmuxControlSequence.swift`(DCS 检测)—— 纯解析,35 单测在 `Tests/`。
- `Session/SessionMachine.swift` —— `reduce(event)->[Effect]` 会话状态机(指数退避重连/后台冻结/前台恢复)。 - `Session/SessionMachine.swift` —— `reduce(event)->[Effect]` 会话状态机(指数退避重连/后台冻结/前台恢复)。**M3 加 mosh 三相** `moshActive/moshParked/moshResuming` + 事件 `moshEstablished/moshHealthy/moshExited/resumeWatchdogFired` + 效果 `nudgeResume/scheduleResumeWatchdog/teardownMosh`:mosh 接管即拆 SSH、后台冻结不重建、前台打唤醒脉冲等 SSP 续、两次脉冲无效兜底全量重建。
- `packages/TXTransport/Sources/` - `packages/TXTransport/Sources/`
- `TXTransport/Transport.swift`(协议+SSHConfig+SSHError)、`SSHSession.swift`(libssh2;`preconnectedFD` 支持外部 fd;事件循环 poll+非阻塞)。 - `TXTransport/Transport.swift`(协议+SSHConfig+SSHError)、`SSHSession.swift`(libssh2;`preconnectedFD` 支持外部 fd;事件循环 poll+非阻塞)。
- `TXTransport/MoshSession.swift`(M2):驱动 `mosh_main`;pipe↔FILE* 桥(in/out)、Foundation.Thread 跑阻塞主循环+`pthread_self()` 供 `pthread_kill(SIGWINCH)` resize、忽略 SIGPIPE、setenv UTF-8 locale。 - `TXTransport/MoshSession.swift`(M2):驱动 `mosh_main`;pipe↔FILE* 桥(in/out)、Foundation.Thread 跑阻塞主循环+`pthread_self()` 供 `pthread_kill(SIGWINCH)` resize、忽略 SIGPIPE、setenv UTF-8 locale。
- `CSSH/`(C target:libssh2 头 + modulemap,供 `import CSSH`)、`CSSHCore`(binaryTarget,library-only)。 - `CSSH/`(C target:libssh2 头 + modulemap,供 `import CSSH`)、`CSSHCore`(binaryTarget,library-only)。
- `CMosh/`(C target:`moshiosbridge.h`(mosh_main)+modulemap+占位 shim.c,供 `import CMosh`)、`MoshCore`(binaryTarget,library-only);TXTransport 链 `libc++`/`libz`(mosh 是 C++ 且 compressor 用 zlib;CommonCrypto 属 libSystem 自动解析)。 - `CMosh/`(C target:`moshiosbridge.h`(mosh_main)+modulemap+占位 shim.c,供 `import CMosh`)、`MoshCore`(binaryTarget,library-only);TXTransport 链 `libc++`/`libz`(mosh 是 C++ 且 compressor 用 zlib;CommonCrypto 属 libSystem 自动解析)。
- `TXCore/Mosh/MoshConnect.swift`:`MoshConnectScanner` 流式扫描 `MOSH CONNECT <port> <key>`(注意 Swift 视 `\r\n` 为单 Character,行尾判定须用 `isNewline`)。 - `TXCore/Mosh/MoshConnect.swift`:`MoshConnectScanner` 流式扫描 `MOSH CONNECT <port> <key>`(注意 Swift 视 `\r\n` 为单 Character,行尾判定须用 `isNewline`)。
- **M3 快恢复链**:`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` 前台执行"唤醒脉冲三连"(WakeUp+Rebind+nudge)+healthy 轮询(探针增长→`moshHealthy`)+恢复看门狗+`beginBackgroundTask`。
- `apps/TerminalX/iOS/` - `apps/TerminalX/iOS/`
- `SSHTerminalModel.swift` —— 核心编排:`OutputGate`(surface-ready 缓冲)、`TransportHolder`、`TsnetManager`(tsnet 节点 up/dial+重试)、`TsnetError`、`TmuxRouter`(raw/gateway 字节分流)、状态机驱动 startSession(直连/tsnet 分支)、`autoConnectIfConfigured`(启动参数)。 - `SSHTerminalModel.swift` —— 核心编排:`OutputGate`(surface-ready 缓冲)、`TransportHolder`、`TsnetManager`(tsnet 节点 up/dial+重试)、`TsnetError`、`TmuxRouter`(raw/gateway 字节分流)、状态机驱动 startSession(直连/tsnet 分支)、`autoConnectIfConfigured`(启动参数)。
- `ContentView.swift` —— 分支:`tmuxController != nil`→`TmuxTabbedView`(tab 条+活动窗口);否则 `RawTerminalView`;`TsnetProbeView`(纯自检);`ConnectionForm`。 - `ContentView.swift` —— 分支:`tmuxController != nil`→`TmuxTabbedView`(tab 条+活动窗口);否则 `RawTerminalView`;`TsnetProbeView`(纯自检);`ConnectionForm`。
@@ -74,8 +75,9 @@
## 7. 下一步建议(按价值) ## 7. 下一步建议(按价值)
1. **M2 mosh 打磨**(功能已通,打磨项):`activateMosh` 写死 80x24 初值(靠首次 resize 纠正);SSH 会话在 mosh 接管后保持 idle 未关(占一条连接);`MoshSession.close()` 后 mosh 线程可能滞留(阻塞主循环,见 TODO);moshiosbridge.cc 顶部 `fwrite("Hello from the Bridge!")` 调试行可清理;`SSHTerminalModel` 里 `NSLog("MOSHDBG …")` 诊断日志可按需保留/删除;mosh-server 引导后远端会累积 detached 会话(`mosh-server new` 每次新建),可考虑复用或清理。**挂起→恢复<3s 门(M3)待专门测**。 1. **M2 mosh 打磨**(功能已通,打磨项):`activateMosh` 写死 80x24 初值(靠首次 resize 纠正);SSH 会话在 mosh 接管后保持 idle 未关(占一条连接);`MoshSession.close()` 后 mosh 线程可能滞留(阻塞主循环,见 TODO);moshiosbridge.cc 顶部 `fwrite("Hello from the Bridge!")` 调试行可清理;`SSHTerminalModel` 里 `NSLog("MOSHDBG …")` 诊断日志可按需保留/删除;mosh-server 引导后远端会累积 detached 会话(`mosh-server new` 每次新建),可考虑复用或清理。**挂起→恢复<3s 门(M3)待专门测**。
3. **tmux 多 pane 分屏**:`TmuxLayout` 已能解析布局树 → 把 window 内多 pane 渲染成 SwiftUI 分屏(每 pane 一 surface)。 3. **M3 真机深挂起补测**:模拟器不复现 socket defunct、且看门狗会杀掉被 `kill -STOP` 冻结过久的 app(>~30s 概率被杀),故 WG 密钥过期(>180s)/DERP 死链/socket defunct 路径需**真机锁屏数分钟**用例补测(`MoshRelay.Rebind`/`hop_port` 的正确性靠代码 + 真机)。
4. **M4 安全**:known_hosts 固定 + SE 密钥。 4. **tmux 多 pane 分屏**:`TmuxLayout` 已能解析布局树 → 把 window 内多 pane 渲染成 SwiftUI 分屏(每 pane 一 surface)。
5. **M4 安全 + 冷启动 mosh 重连**:known_hosts 固定 + SE 密钥;mosh 进程死后重连原 detached 会话需持久化 MOSH_KEY+port 且改 mosh 序列化 SSP 序号/终端状态(Blink 式,大活)。
5. **产品化**:主机列表持久化(Keychain)、多标签(非 tmux)、软键盘运维工具栏(GhosttyKit 内建,接线即可)。 5. **产品化**:主机列表持久化(Keychain)、多标签(非 tmux)、软键盘运维工具栏(GhosttyKit 内建,接线即可)。
## 8. 测试资源(用户提供,**凭据勿写入仓库/勿硬编码**,每次向用户索取) ## 8. 测试资源(用户提供,**凭据勿写入仓库/勿硬编码**,每次向用户索取)

View File

@@ -22,6 +22,13 @@ public struct SessionMachine: Sendable, Equatable {
case failed(reason: String) case failed(reason: String)
/// 用户主动关闭。 /// 用户主动关闭。
case closed 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 { public enum Event: Sendable, Equatable {
@@ -35,6 +42,11 @@ public struct SessionMachine: Sendable, Equatable {
case enteredForeground case enteredForeground
case closeRequested case closeRequested
case retryRequested // 用户手动重试(从 failed 复位) 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 { public enum Effect: Sendable, Equatable {
@@ -43,6 +55,11 @@ public struct SessionMachine: Sendable, Equatable {
case cancelReconnectTimer case cancelReconnectTimer
case teardownTransport case teardownTransport
case notify(String) // UI 提示 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 public private(set) var phase: Phase = .idle
@@ -52,11 +69,18 @@ public struct SessionMachine: Sendable, Equatable {
/// 退避基数与上限(毫秒)。 /// 退避基数与上限(毫秒)。
private let baseDelayMS: Int private let baseDelayMS: Int
private let maxDelayMS: 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.maxReconnectAttempts = maxReconnectAttempts
self.baseDelayMS = baseDelayMS self.baseDelayMS = baseDelayMS
self.maxDelayMS = maxDelayMS self.maxDelayMS = maxDelayMS
self.resumeWatchdogMS = resumeWatchdogMS
self.maxResumePulses = maxResumePulses
} }
/// 指数退避:base * 2^(attempt-1),封顶 maxDelayMS。 /// 指数退避:base * 2^(attempt-1),封顶 maxDelayMS。
@@ -135,10 +159,60 @@ public struct SessionMachine: Sendable, Equatable {
phase = .backgroundParked phase = .backgroundParked
return [.cancelReconnectTimer] 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): case (_, .closeRequested):
phase = .closed phase = .closed
return [.cancelReconnectTimer, .teardownTransport] return [.cancelReconnectTimer, .cancelResumeWatchdog, .teardownTransport, .teardownMosh]
// 其它组合不改变状态 // 其它组合不改变状态
default: default:

View File

@@ -125,4 +125,78 @@ struct SessionMachineTests {
#expect(m2.reduce(.retryRequested) == [.startConnect]) #expect(m2.reduce(.retryRequested) == [.startConnect])
#expect(m2.phase == .connecting) #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, 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); 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 */ #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) }
}
/// M3:mosh 最近收到新(已认证)服务器状态的时刻(mosh 单调钟 ms);0=从未。
/// 前台恢复时取基线、轮询其增长即判定 SSP 已续(healthy)。
public func lastHeardMs() -> UInt64 { UInt64(mosh_last_heard_ms()) }
/// 更新终端尺寸并通知 mosh(SIGWINCH → process_resize)。 /// 更新终端尺寸并通知 mosh(SIGWINCH → process_resize)。
public func resize(cols: Int, rows: Int) { public func resize(cols: Int, rows: Int) {
winPtr.pointee.ws_col = UInt16(cols) winPtr.pointee.ws_col = UInt16(cols)

View File

@@ -62,6 +62,16 @@
#include "networktransport.cc" #include "networktransport.cc"
#include <atomic>
#include <stdint.h>
/* M3 探针:记录最近一次收到服务器数据报的时刻(mosh 单调 timestamp() ms)。
* process_network_input 仅在网络 socket 可读时调用,loopback 上只有服务器经 relay 转来的包,
* 故"进入该函数"≈"听到服务器"(含纯 ack,空闲屏幕也能检出恢复)。
* Swift 侧前台恢复时记基线值、轮询其增长 → 判定 SSP 已续("healthy")。跨线程用 atomic。 */
std::atomic<uint64_t> g_mosh_last_heard_ms{ 0 };
extern "C" unsigned long long mosh_last_heard_ms( void ) { return (unsigned long long)g_mosh_last_heard_ms.load(); }
void iOSClient::resume( void ) void iOSClient::resume( void )
{ {
/* Restore termios state */ /* Restore termios state */
@@ -73,8 +83,8 @@ void iOSClient::resume( void )
// /* Put terminal in application-cursor-key mode */ // /* Put terminal in application-cursor-key mode */
// swrite( out_fd, display.open().c_str() ); // swrite( out_fd, display.open().c_str() );
// /* Flag that outer terminal state is unknown */ /* M3:SIGCONT(前台唤醒)→ 标记外层终端状态未知,下一帧全屏重绘。 */
// repaint_requested = true; repaint_requested = true;
} }
void iOSClient::init( void ) void iOSClient::init( void )
@@ -285,6 +295,9 @@ void iOSClient::process_network_input( void )
{ {
network->recv(); network->recv();
/* M3 探针:收到服务器数据报 → 记录时刻供前台恢复判定(见上方说明)。 */
g_mosh_last_heard_ms.store( timestamp() );
/* Now give hints to the overlays */ /* Now give hints to the overlays */
overlays.get_notification_engine().server_heard( network->get_latest_remote_state().timestamp ); overlays.get_notification_engine().server_heard( network->get_latest_remote_state().timestamp );
overlays.get_notification_engine().server_acked( network->get_sent_state_acked_timestamp() ); overlays.get_notification_engine().server_acked( network->get_sent_state_acked_timestamp() );

View File

@@ -10,4 +10,10 @@ extern "C"
int mosh_main(FILE *f_in, FILE *f_out, struct winsize *window_size, 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); 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 #endif

View File

@@ -10,6 +10,7 @@ import (
"net" "net"
"os" "os"
"strconv" "strconv"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -109,9 +110,12 @@ func (n *Node) DialTCPFD(hostOrIP string, port int, timeoutMs int) (int64, error
// tailnet 内 host:moshPort,回包再转回"最近一次上行的 client 源地址"(吸收 mosh 的 roaming/端口跳变)。 // tailnet 内 host:moshPort,回包再转回"最近一次上行的 client 源地址"(吸收 mosh 的 roaming/端口跳变)。
// 与 DialTCPFD 的差异:mosh 内部自建 UDP socket 并 connect(),无法注入外部 fd,故用 loopback relay。 // 与 DialTCPFD 的差异:mosh 内部自建 UDP socket 并 connect(),无法注入外部 fd,故用 loopback relay。
type MoshRelay struct { type MoshRelay struct {
local *net.UDPConn // 真实内核 loopback UDP socket(mosh-client 的对端) mu sync.Mutex
tsConn net.Conn // tsnet netstack UDP 虚拟连接(到 mosh-server) local *net.UDPConn // 当前 loopback socket;Rebind 在同端口替换(关旧 socket 令旧上行泵退出)
lastCli atomic.Pointer[net.UDPAddr] localPort int // 固定本地端口(mosh 始终发往此端口,Rebind 保持不变)
tsConn net.Conn // tsnet netstack UDP 虚拟连接(纯内存,进程冻结无损,无需重建)
lastCli atomic.Pointer[net.UDPAddr]
closed bool
} }
// StartMoshRelay 建立中继并启动双向泵,返回句柄。Swift 用 LocalPort() 作为 mosh_main 的 port // StartMoshRelay 建立中继并启动双向泵,返回句柄。Swift 用 LocalPort() 作为 mosh_main 的 port
@@ -131,56 +135,108 @@ func (n *Node) StartMoshRelay(host string, moshPort int, timeoutMs int) (*MoshRe
return nil, err return nil, err
} }
r := &MoshRelay{local: local, tsConn: tsConn} r := &MoshRelay{
local: local,
// 上行:client(loopback) → tsnet(server)。记录最近 client 源地址供下行回送。 localPort: local.LocalAddr().(*net.UDPAddr).Port,
go func() { tsConn: tsConn,
buf := make([]byte, 65536) }
for { go r.pumpUp(local) // 上行泵(随具体 socket 生成/退出,Rebind 会换新泵)
nr, cliAddr, err := local.ReadFromUDP(buf) go r.pumpDown() // 下行泵(绑定稳定 tsConn,跨 Rebind 存活)
if nr > 0 {
r.lastCli.Store(cliAddr)
if _, werr := tsConn.Write(buf[:nr]); werr != nil {
return
}
}
if err != nil {
return
}
}
}()
// 下行:tsnet(server) → client(loopback 最近源地址)。逐报文保边界。
go func() {
buf := make([]byte, 65536)
for {
nr, err := tsConn.Read(buf)
if nr > 0 {
if cli := r.lastCli.Load(); cli != nil {
if _, werr := local.WriteToUDP(buf[:nr], cli); werr != nil {
return
}
}
}
if err != nil {
return
}
}
}()
return r, nil return r, nil
} }
// LocalPort 返回 loopback relay 监听的端口(mosh_main 的 port 参数)。 // pumpUp:client(loopback) → tsnet(server)。绑定某个具体 socket;该 socket 关闭即退出
func (r *MoshRelay) LocalPort() int { // (Rebind/Close 触发)。记录最近 client 源地址供下行回送(吸收 mosh 的 hop_port 换端口)。
return r.local.LocalAddr().(*net.UDPAddr).Port func (r *MoshRelay) pumpUp(local *net.UDPConn) {
buf := make([]byte, 65536)
for {
nr, cli, err := local.ReadFromUDP(buf)
if nr > 0 {
r.lastCli.Store(cli)
if _, werr := r.tsConn.Write(buf[:nr]); werr != nil {
return
}
}
if err != nil {
return
}
}
} }
// Close 关闭中继(两条泵 goroutine 随即因 conn 关闭而退出)。 // pumpDown:tsnet(server) → client(最近 loopback 源地址)。绑定稳定 tsConn,跨 Rebind 存活;
// 每次写入"当前"socket(Rebind 后自动跟上)。逐报文保边界。
func (r *MoshRelay) pumpDown() {
buf := make([]byte, 65536)
for {
nr, err := r.tsConn.Read(buf)
if nr > 0 {
if cli := r.lastCli.Load(); cli != nil {
r.mu.Lock()
l := r.local
r.mu.Unlock()
if l != nil {
l.WriteToUDP(buf[:nr], cli)
}
}
}
if err != nil {
return
}
}
}
// LocalPort 返回固定的 loopback relay 端口(mosh_main 的 port 参数,跨 Rebind 不变)。
func (r *MoshRelay) LocalPort() int {
return r.localPort
}
// Rebind 在同端口重开 loopback socket(iOS 前台恢复用:挂起后旧 socket 可能被系统回收为 defunct)。
// 关旧 socket → 旧上行泵退出 → 新泵接管;下行泵绑定稳定 tsConn 无需重启。端口被占等返回 error(由上层升级为全量重建)。
func (r *MoshRelay) Rebind() error {
r.mu.Lock()
defer r.mu.Unlock()
if r.closed {
return nil
}
if r.local != nil {
r.local.Close() // 释放端口 + 令旧 pumpUp 退出(UDP 无 TIME_WAIT,同端口可立即重绑)
}
nl, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: r.localPort})
if err != nil {
r.local = nil
return err
}
r.local = nl
go r.pumpUp(nl)
return nil
}
// Close 关闭中继(两条泵随 conn 关闭而退出)。
func (r *MoshRelay) Close() error { func (r *MoshRelay) Close() error {
r.local.Close() r.mu.Lock()
r.closed = true
l := r.local
r.local = nil
r.mu.Unlock()
if l != nil {
l.Close()
}
return r.tsConn.Close() return r.tsConn.Close()
} }
// WakeUp 在 iOS 前台恢复时主动唤醒 tsnet 网络层:注入链路变化事件 + Rebind/重新 STUN,
// 把"等首次写失败才发现 DERP/路径已死"变成"立刻重建",配合 mosh SSP 实现秒级恢复。
func (n *Node) WakeUp() {
sys := n.srv.Sys()
if mon, ok := sys.NetMon.GetOK(); ok {
mon.InjectEvent()
}
if ms, ok := sys.MagicSock.GetOK(); ok {
ms.Rebind()
ms.ReSTUN("foreground")
}
}
// PeersJSON 返回 tailnet 内其它节点列表(name/ip/online/os)的 JSON,用于选择连接目标。 // PeersJSON 返回 tailnet 内其它节点列表(name/ip/online/os)的 JSON,用于选择连接目标。
func (n *Node) PeersJSON() string { func (n *Node) PeersJSON() string {
lc, err := n.srv.LocalClient() lc, err := n.srv.LocalClient()