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

@@ -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 )
@@ -284,7 +294,10 @@ void iOSClient::output_new_frame( void )
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()