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 IPrelay=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`
**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/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 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 SSHmoshActive 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) 次)"
}
}
}

View File

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

View File

@@ -21,20 +21,6 @@
<key>SupportedPlatformVariant</key>
<string>simulator</string>
</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>
<key>BinaryPath</key>
<string>libMoshCore.a</string>
@@ -49,6 +35,20 @@
<key>SupportedPlatform</key>
<string>macos</string>
</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>
<key>CFBundlePackageType</key>
<string>XFWK</string>

View File

@@ -4,6 +4,20 @@
<dict>
<key>AvailableLibraries</key>
<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>
<key>BinaryPath</key>
<string>TsnetBridge.framework/TsnetBridge</string>
@@ -21,20 +35,6 @@
<key>SupportedPlatformVariant</key>
<string>simulator</string>
</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>
<key>CFBundlePackageType</key>
<string>XFWK</string>

View File

@@ -27,13 +27,18 @@ tailnet 内 host:moshPort回包再转回"最近一次上行的 client 源地
- (nonnull instancetype)initWithRef:(_Nonnull id)ref;
- (nonnull instancetype)init;
/**
* Close 关闭中继(两条泵 goroutine 随即因 conn 关闭而退出)。
* Close 关闭中继(两条泵 conn 关闭而退出)。
*/
- (BOOL)close:(NSError* _Nullable* _Nullable)error;
/**
* LocalPort 返回 loopback relay 监听的端口mosh_main 的 port 参数)。
* LocalPort 返回固定的 loopback relay 端口mosh_main 的 port 参数,跨 Rebind 不变)。
*/
- (long)localPort;
/**
* Rebind 在同端口重开 loopback socketiOS 前台恢复用:挂起后旧 socket 可能被系统回收为 defunct
关旧 socket → 旧上行泵退出 → 新泵接管;下行泵绑定稳定 tsConn 无需重启。端口被占等返回 error由上层升级为全量重建
*/
- (BOOL)rebind:(NSError* _Nullable* _Nullable)error;
@end
/**
@@ -77,6 +82,11 @@ Swift 侧 close(fd) 会触发泵结束并关闭 tsConn。返回 -1 + error 表
gomobile 友好签名基本类型入参、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
/**

View File

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

View File

@@ -27,13 +27,18 @@ tailnet 内 host:moshPort回包再转回"最近一次上行的 client 源地
- (nonnull instancetype)initWithRef:(_Nonnull id)ref;
- (nonnull instancetype)init;
/**
* Close 关闭中继(两条泵 goroutine 随即因 conn 关闭而退出)。
* Close 关闭中继(两条泵 conn 关闭而退出)。
*/
- (BOOL)close:(NSError* _Nullable* _Nullable)error;
/**
* LocalPort 返回 loopback relay 监听的端口mosh_main 的 port 参数)。
* LocalPort 返回固定的 loopback relay 端口mosh_main 的 port 参数,跨 Rebind 不变)。
*/
- (long)localPort;
/**
* Rebind 在同端口重开 loopback socketiOS 前台恢复用:挂起后旧 socket 可能被系统回收为 defunct
关旧 socket → 旧上行泵退出 → 新泵接管;下行泵绑定稳定 tsConn 无需重启。端口被占等返回 error由上层升级为全量重建
*/
- (BOOL)rebind:(NSError* _Nullable* _Nullable)error;
@end
/**
@@ -77,6 +82,11 @@ Swift 侧 close(fd) 会触发泵结束并关闭 tsConn。返回 -1 + error 表
gomobile 友好签名基本类型入参、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
/**

View File

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

View File

@@ -6,7 +6,7 @@
---
## 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 模拟器 slicetsnet 桥加 UDP relay(`StartMoshRelay`)`MoshSession`(pipe↔FILE*/pthread/SIGWINCH)+`MoshConnectScanner`(TXCore,39 单测)+`SSHTerminalModel` 编排SSH 引导 mosh-server→解析 MOSH CONNECT→接管
> - **LAN 直连端到端验证**192.168.9.199mosh-server 存活 104s≫60s 无客户端超时+子 shell+心跳=真连。
> - **mosh over tsnet 端到端验证**vohive-vm 100.69.201.101app 日志 `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 首连路径预热超时 |
| **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 证伪) |
| **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 密钥 待做 |
| **M5** 合规提审 | ⏸️ 暂缓 | 用户指示暂缓 |
## 5. 代码地图与数据流
- `packages/TXCore/Sources/TXCore/`
- `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/`
- `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。
- `CSSH/`C targetlibssh2 头 + modulemap`import CSSH`)、`CSSHCore`binaryTargetlibrary-only
- `CMosh/`C target`moshiosbridge.h`(mosh_main)+modulemap+占位 shim.c`import CMosh`)、`MoshCore`binaryTargetlibrary-onlyTXTransport 链 `libc++`/`libz`mosh 是 C++ 且 compressor 用 zlibCommonCrypto 属 libSystem 自动解析)。
- `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/`
- `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`
@@ -74,8 +75,9 @@
## 7. 下一步建议(按价值)
1. **M2 mosh 打磨**(功能已通,打磨项):`activateMosh` 写死 80x24 初值(靠首次 resize 纠正SSH 会话在 mosh 接管后保持 idle 未关(占一条连接);`MoshSession.close()` 后 mosh 线程可能滞留(阻塞主循环,见 TODOmoshiosbridge.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)。
4. **M4 安全**known_hosts 固定 + SE 密钥
3. **M3 真机深挂起补测**模拟器不复现 socket defunct且看门狗会杀掉被 `kill -STOP` 冻结过久的 app>~30s 概率被杀),故 WG 密钥过期(>180s)/DERP 死链/socket defunct 路径需**真机锁屏数分钟**用例补测(`MoshRelay.Rebind`/`hop_port` 的正确性靠代码 + 真机)。
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 内建,接线即可)。
## 8. 测试资源(用户提供,**凭据勿写入仓库/勿硬编码**,每次向用户索取)

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)

View File

@@ -62,6 +62,16 @@
#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 )
{
/* Restore termios state */
@@ -73,8 +83,8 @@ void iOSClient::resume( void )
// /* Put terminal in application-cursor-key mode */
// swrite( out_fd, display.open().c_str() );
// /* Flag that outer terminal state is unknown */
// repaint_requested = true;
/* M3SIGCONT前台唤醒→ 标记外层终端状态未知,下一帧全屏重绘。 */
repaint_requested = true;
}
void iOSClient::init( void )
@@ -285,6 +295,9 @@ void iOSClient::process_network_input( void )
{
network->recv();
/* M3 探针:收到服务器数据报 → 记录时刻供前台恢复判定(见上方说明)。 */
g_mosh_last_heard_ms.store( timestamp() );
/* Now give hints to the overlays */
overlays.get_notification_engine().server_heard( network->get_latest_remote_state().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,
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

View File

@@ -10,6 +10,7 @@ import (
"net"
"os"
"strconv"
"sync"
"sync/atomic"
"time"
@@ -109,9 +110,12 @@ func (n *Node) DialTCPFD(hostOrIP string, port int, timeoutMs int) (int64, error
// tailnet 内 host:moshPort回包再转回"最近一次上行的 client 源地址"(吸收 mosh 的 roaming/端口跳变)。
// 与 DialTCPFD 的差异mosh 内部自建 UDP socket 并 connect(),无法注入外部 fd故用 loopback relay。
type MoshRelay struct {
local *net.UDPConn // 真实内核 loopback UDP socketmosh-client 的对端)
tsConn net.Conn // tsnet netstack UDP 虚拟连接(到 mosh-server
lastCli atomic.Pointer[net.UDPAddr]
mu sync.Mutex
local *net.UDPConn // 当前 loopback socketRebind 在同端口替换(关旧 socket 令旧上行泵退出
localPort int // 固定本地端口mosh 始终发往此端口Rebind 保持不变)
tsConn net.Conn // tsnet netstack UDP 虚拟连接(纯内存,进程冻结无损,无需重建)
lastCli atomic.Pointer[net.UDPAddr]
closed bool
}
// StartMoshRelay 建立中继并启动双向泵返回句柄。Swift 用 LocalPort() 作为 mosh_main 的 port
@@ -131,56 +135,108 @@ func (n *Node) StartMoshRelay(host string, moshPort int, timeoutMs int) (*MoshRe
return nil, err
}
r := &MoshRelay{local: local, tsConn: tsConn}
// 上行client(loopback) → tsnet(server)。记录最近 client 源地址供下行回送。
go func() {
buf := make([]byte, 65536)
for {
nr, cliAddr, err := local.ReadFromUDP(buf)
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
}
}
}()
r := &MoshRelay{
local: local,
localPort: local.LocalAddr().(*net.UDPAddr).Port,
tsConn: tsConn,
}
go r.pumpUp(local) // 上行泵(随具体 socket 生成/退出Rebind 会换新泵)
go r.pumpDown() // 下行泵(绑定稳定 tsConn跨 Rebind 存活)
return r, nil
}
// LocalPort 返回 loopback relay 监听的端口mosh_main 的 port 参数)。
func (r *MoshRelay) LocalPort() int {
return r.local.LocalAddr().(*net.UDPAddr).Port
// pumpUpclient(loopback) → tsnet(server)。绑定某个具体 socket该 socket 关闭即退出
// Rebind/Close 触发)。记录最近 client 源地址供下行回送(吸收 mosh 的 hop_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 关闭而退出)。
// pumpDowntsnet(server) → client(最近 loopback 源地址)。绑定稳定 tsConn跨 Rebind 存活;
// 每次写入"当前"socketRebind 后自动跟上)。逐报文保边界。
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 socketiOS 前台恢复用:挂起后旧 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 {
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()
}
// 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用于选择连接目标。
func (n *Node) PeersJSON() string {
lc, err := n.srv.LocalClient()