复刻tmux的魔法:Muxy的muxy-session守护进程如何实现持久化终端会话
【免费下载链接】muxyLightweight and Memory efficient terminal for Mac built with SwiftUI and libghostty项目地址: https://gitcode.com/gh_mirrors/muxy1/muxy
Muxy是一款用 SwiftUI 和 libghostty 构建的轻量 Mac 终端,它的持久化终端会话能力由隐藏的muxy-session守护进程实现:哪怕你直接关闭窗口甚至退出应用,正在跑的编译、测试任务都不会中断,重新打开 Muxy 即可"续接"原会话。这篇文章将拆解这套机制的完整原理。
一、为什么终端会话需要"活下来" 💥
用过 tmux 的人都有同感:会话被托管在后台守护进程里,窗口只是"观察者",所以关掉终端会话依然存活。
Muxy 把同样的魔法搬进了原生应用,但实现完全不同——没有外部依赖,只有一个静态二进制muxy-session和一个 Unix 域套接字。它解决的真实痛点是:
- 编译到 90% 时不小心按了
Cmd+Q,一切前功尽弃; - 想看一眼手机,合上笔记本盖子,回到工位发现任务已被杀掉;
- 想换一个窗口尺寸重开标签页,又不想丢失当前运行状态。
二、整体架构:一个二进制,两种身份
muxy-session的入口在 main.swift,它只做一件事——根据子命令分流:
| 子命令 | 身份 | 职责 |
|---|---|---|
daemon --socket <path> | 守护进程 | 监听本地 socket,托管所有 PTY 会话 |
attach | 前台客户端 | 接管真实终端,把按键/画面与守护进程双向桥接 |
应用侧的"接线员"是 PersistentSessionService.swift:开启持久化后,Muxy 不再直接启动 shell,而是启动muxy-session attach,并通过环境变量注入会话身份(MUXY_SESSION_ID、MUXY_SESSION_SOCKET、MUXY_SESSION_CWD等)。于是真实的 shell 进程跑在守护进程名下,应用窗口退化为一个"显示器"。
守护进程与 attach 客户端之间的通信采用极简的二进制帧协议,每种帧类型定义在 SessionFrame.swift 中:
attach / attached:握手,声明"我要接管哪个会话";input / output:按键流与屏幕输出的双向搬运;resize:窗口尺寸同步(SIGWINCH信号的转发);kill / killAll / list / info:会话管理与查询。
三、守护进程的诞生与"无人自动退场" ⚙️
muxy-session的守护进程遵循惰性启动:谁需要谁拉起。attach 客户端连接 socket 失败时,会立刻用posix_spawn以POSIX_SPAWN_SETSID标志在后台生成守护进程(见 SessionAttachClient.swift 中的launchDaemon),随后进入重试连接循环,直到握手成功。
防止"两个守护进程抢一个 socket"的,是一枚文件锁:SessionSocket.swift 中的acquireLock用flock(LOCK_EX | LOCK_NB)锁定control.sock.lock,抢不到锁的新进程会安静退出——这就是为什么守护进程全局唯一,却无需任何注册中心。
守护进程主循环 SessionDaemon.swift 是一个教科书级的poll事件循环,同时盯着三类文件描述符:
- 监听 socket:新客户端接入;
- SIGCHLD 信号管道:子进程退出通知;
- 各会话 PTY master + 各客户端连接:数据双向流动。
最妙的是空闲自毁:当会话和连接都为空时,poll超时设为 10 秒(idleTimeoutMilliseconds),超时即shutdown()——清理所有描述符、删除 socket 与锁文件后退出。用户机器上不会留下任何"僵尸服务"。
四、PTY:让 shell 真正"断线不断命"的核心
守护进程用forkpty在 SessionPTY.swift 中孵化每个会话:父进程持有 PTY master,子进程execve进入用户 shell。关键点在于子进程先把SIGPIPE / SIGHUP / SIGINT等信号全部复位为默认处理——这样它表现得像一个真实终端里运行的普通进程,Ctrl+C、挂起等行为全部符合直觉。
清理过程同样讲究,采用三级递进策略:
- 先向整个进程组发
SIGHUP(模拟"终端被拔出"),给进程体面退出的机会; - 20 轮轮询后仍存活,升级
SIGTERM; - 再坚持 50 轮,直接
SIGKILL。
而且它通过sysctl枚举整个进程组的成员逐个通知,避免vim的子进程、python的 fork 后代变成无主孤儿。
五、重连看到"历史":256KB 环形回放缓冲 🎞️
重新 attach 时凭什么能"看到离开前的画面"?答案是 SessionReplayBuffer.swift 中的环形缓冲区:守护进程为每个会话维护 256KB 滚动输出(replayCapacity),新客户端接入时按 32KB 分片重放,随后再向 shell 进程组补发一次SIGWINCH请求全屏重绘(requestRedraw)。
这块代码比想象中精细得多:
- 备用屏幕感知:识别 ANSI 转义序列,检测你当时是否在
vim/htop的备用屏幕上——若是,重放时返回空缓冲,交给程序自己重绘,避免花屏; - UTF-8 边界保护:回放裁剪点永远不会把一个汉字或 emoji 从中间切开;
- 残缺转义序列清理:头部若残留半个 CSI/OSC 序列会被安全丢弃。
六、安全细节:只有"你自己"能连进来 🔒
socket 是本地文件,理论上同机其他用户也能尝试连接。Muxy 做了四层防护:
- 路径解析:优先
~/Library/Application Support/Muxy/sessions/control.sock,超长时回退/tmp/muxy-<uid>/,且回退目录必须属主校验通过(见 PersistentSessionPaths.swift); - 权限收紧:
umask(0o077)+ socketchmod 0o600,锁文件同样 0600; - 身份核验:每个新连接用
LOCAL_PEERCRED取出对端 UID,与守护进程自身 UID 不符直接拒绝; - 协议版本:attach 请求携带版本号,跨版本混用会明确报错而非静默错乱。
会话的窗口尺寸策略则由 SessionWindowSizePolicy.swift 统一裁决,保证0x0这类非法尺寸不会把 PTY 撑坏。
七、如何在 Muxy 中体验持久化会话 🚀
- 打开设置 → 终端,启用持久化会话(偏好定义见 TerminalPersistentSessionPreferences.swift);
- 在任意项目里跑一个长任务(比如
make build); - 直接关闭窗口或
Cmd+Q退出 Muxy——任务在后台守护进程中继续跑; - 重新打开 Muxy,PersistentSessionRecovery.swift 会探测遗留的 socket 与
control.sock,把标签页、工作目录、甚至当时运行的命令一起恢复回来。
如果想亲手验证,可以在会话运行时于muxy-session日志旁观察 socket 文件,或在另一个终端里手动执行守护进程二进制查看daemon / attach两种用法。
总结:小而美的系统设计
muxy-session用不到几百个源文件实现了 tmux 最核心的持久化能力,设计取舍非常清晰:
- 无依赖:纯 POSIX API,一个静态二进制塞进应用 Bundle;
- 自管理:惰性启动、文件锁单例、空闲自毁,不留后台负担;
- 体验优先:环形回放 + 备用屏感知,让"重连"几乎无缝;
- 安全默认:本地 socket 的每一条缝隙都有对应防线。
对开发者而言,MuxySession/ 与 MuxySessionProtocol/ 两个目录本身就是一份关于 Unix 域 socket、PTY 与poll事件循环的优质教学材料——比读 tmux 源码友好得多。
【免费下载链接】muxyLightweight and Memory efficient terminal for Mac built with SwiftUI and libghostty项目地址: https://gitcode.com/gh_mirrors/muxy1/muxy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考