Skip to content

Commit 0272442

Browse files
committed
docs(terminal): discussion draft on terminal instance survival and restart reattach
Records a discussion (no decision, no code change) on what happens to pty terminal instances and their inner processes (e.g. opencode cli) when the myworktree daemon exits, and what it would take to keep them alive, traceable, and re-attachable after a restart. Key facts captured with file:line references: - The terminal dies with the daemon via PTY hangup, not an explicit kill: myworktree holds the pty master fd in the per-instance Handle (internal/instance/pty/driver.go) and Shutdown deliberately does not stop pty kinds (internal/app/app.go) — process exit closes the master, the kernel sends SIGHUP to the foreground process group and session leader. nohup/setsid-escaped processes become untracked orphans (no PDEATHSIG anywhere). - The framework already has a RestartSurvivor/Reattach path, implemented by reasonix only (internal/framework/methods.go ReconcileRunningOnStartup); pty is explicitly excluded because its in-memory stdin/stdout bindings and ring buffer cannot resume. - Converged requirement: survive a daemon restart and re-attach the session as-is, uninterrupted. The single necessary and sufficient condition: move PTY master fd ownership out of myworktree (tmux with an isolated socket is the mature form; a self-built supervisor is the alternative). - Documents the honest boundaries of "uninterrupted", the long-term unreclaimed-risk list, and the open decision forks (scrollback depth, hard dependency vs fallback, Shutdown semantics, orphan reaping). Also flags an adjacent pre-existing hole: dsh-web never implements Reattach, so a kill -9 daemon leaves dsh processes orphaned on their ports. Co-authored-by: linletian <linletian@users.noreply.github.com>
1 parent 4391ba5 commit 0272442

1 file changed

Lines changed: 203 additions & 0 deletions

File tree

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
# 终端实例存活与重启拉回 — 讨论稿
2+
3+
> 状态:讨论稿,未决策,不含实现承诺。记录一次关于 pty 终端实例生命周期的
4+
> 讨论:myworktree 关闭后终端及其内部进程的存亡、能否让它们存活且重启后
5+
> 原样拉回、以及长期未回收的风险。
6+
>
7+
> 所有代码事实均标注文件行号,基于写稿时的 main/develop 代码。
8+
9+
## 1. 问题
10+
11+
- Q1:myworktree 进程关闭后,终端实例(pty kind)及其中的进程(如 opencode
12+
cli)是否自动一起被杀掉?
13+
- Q2:有没有可能让终端实例和内部进程继续存活,并且(a)可追溯是 myworktree
14+
启动的,(b)下次 myworktree 启动后原样拉回,(c)拉回过程中断为零?
15+
- Q3:如果允许长期存活,长期未回收的风险有哪些?
16+
17+
## 2. 现状:终端实例的生命周期(代码事实)
18+
19+
### 2.1 进程拓扑
20+
21+
- 每个终端实例是一个 `zsh -f -i`,经 `creack/pty.Start()` 启动
22+
(`internal/instance/pty/driver.go:87,104`)。creack/pty v1.1.24 的
23+
`Start` 语义是"新会话 + 控制终端"(Setsid + Setctty):zsh 是会话首领,
24+
PTY slave 是它的控制终端。
25+
- **PTY master fd(`h.ptmx`)持有在 myworktree 进程内**,保存在 per-instance
26+
Handle 里(`internal/instance/pty/driver.go:56`)。Resize / SendInput /
27+
pumpLogs 全部通过这个 fd 进行(`driver.go:251,259,262`)。
28+
- 用户在终端里跑的前台程序(如 opencode cli)因交互 shell 的 job control
29+
而拥有独立进程组。
30+
31+
结论:**终端的命脉(master fd)握在 myworktree 手里**。这是后续一切结论的
32+
根源。
33+
34+
### 2.2 关闭链路:Q1 的答案是"必死"
35+
36+
| 场景 | 机制 | 结果 |
37+
|---|---|---|
38+
| 优雅退出 | `Server.Shutdown()`(`internal/app/app.go:697-709`)**不显式杀 pty**(代码注释原文:tty instances "die when their PTY hangs up"),只显式 `StopAllKind(reasonix/dsh)`。myworktree 进程退出 → 内核关闭 ptmx | PTY hangup:内核对前台进程组与会话首领发 SIGHUP(+SIGCONT)。opencode cli(TUI,默认不捕获 HUP)直接终止;zsh 收到 HUP 后退出并把自己的作业 hup 掉 |
39+
| kill -9 / 崩溃 / OOM | 进程死亡 → 所有 fd 关闭 | 同上,同一 hangup 链路 |
40+
41+
即:**终端实例与其中的 opencode cli 会随 myworktree 自动一起死,且不依赖
42+
myworktree 的主动清理**——这是内核级 PTY 语义(谁持有 master fd,谁就是终端
43+
的命脉),不是代码里某个显式 kill 的结果。
44+
45+
**逃逸口(已知)**:终端内自行 `nohup` / `setsid` / `& disown` 的进程会活
46+
下来,但成为**无人追踪的孤儿**——全仓库无任何 `Pdeathsig` 设置(grep 确认),
47+
这些进程躲过 SIGHUP 后彻底失联。这正是 Q3 风险的现成入口。
48+
49+
### 2.3 Reattach 机制现状:Q2 的可行性底子
50+
51+
框架已具备一套"进程存活 + 重启拉回"机制,但 pty 被刻意排除:
52+
53+
- `framework.Kind` 有可选的 `RestartSurvivor` 接口:`Reattach(ctx, id)`
54+
(`internal/framework/kind.go:356-361`)。同处注释明确写着 "Kinds whose
55+
processes die with the server (PTY) do NOT implement this interface"。
56+
- `Manager.ReconcileRunningOnStartup()`(`internal/framework/methods.go:241`)
57+
在启动时对每条 running/starting 记录询问对应 kind:实现了
58+
`RestartSurvivor` 且 `Reattach` 成功 → 记录保持 running;否则标记
59+
stopped。
60+
- **唯一实现者是 reasonix**(`internal/instance/reasonix/kind.go:192`):
61+
serve 子进程用 `Setpgid` 启动(`reasonix/driver.go:274`),pid/port 落盘,
62+
`Health` = `kill(pid,0)` + TCP 探测(`driver.go:531,555`)。myworktree 被
63+
kill -9 后 serve 活着,重启后拉回,框架照常管理它。
64+
- pty 不实现的原因(`docs/ARCHITECTURE.md:90`):in-memory stdin/stdout 绑定
65+
无法在进程重启后恢复;且 PTY 输出 ring buffer 是纯内存态
66+
(`ARCHITECTURE.md:100-102,129`),重启即丢,无法回放。
67+
68+
对照:reasonix 能活,是因为它**不需要 myworktree 持有的 PTY**(自己的日志
69+
文件 + 端口 + pid 文件);pty 的生死绑在 master fd 上,问题无解于"重启后
70+
拉回"层,只能解于"master fd 所有权"层。
71+
72+
### 2.4 既存的孤儿面(顺带发现)
73+
74+
- dsh-web kind:`Setpgid` 管道子进程(`dsh_web/driver.go:325`),Stop 杀进程
75+
组(`driver.go:404-422`),但**没有实现 Reattach**(全仓库仅 reasonix 有)。
76+
myworktree 被 kill -9 后,dsh 进程会作为孤儿存活,重启时
77+
ReconcileRunningOnStartup 只能把记录标 stopped——进程仍占着端口和凭证。
78+
- 终端内 nohup/setsid 逃逸的进程:完全失联(见 2.2)。
79+
80+
这两类就是当前架构下"长期未回收"的真实存量。
81+
82+
## 3. 需求收敛
83+
84+
讨论收敛为一条:**myworktree 重启后,终端会话原样恢复、运行不中断**——
85+
包含 zsh 本体(环境变量、cwd、历史)、前台 TUI 程序(opencode cli)、屏幕
86+
内容与 scrollback。
87+
88+
## 4. 充要条件
89+
90+
只有一个根条件,其余都是它的推论:
91+
92+
> **把 PTY master fd 的所有权从 myworktree 移到一个比 myworktree 命长的持
93+
> 有者。**
94+
95+
推论为六条验收条件:
96+
97+
| # | 条件 | 说明 |
98+
|---|---|---|
99+
| 1 | master 所有权转移 | 持有者必须 setsid、忽略 SIGHUP、独立于用户登录会话,命长于 myworktree |
100+
| 2 | 会话身份可持久化 | 会话名 = 实例 id(如 `mw-<instanceId>`),可探活;写入 KindBlob(现 pty blob 为 `{}`,`pty/driver.go:237`) |
101+
| 3 | 屏幕原样回放 | 回放 pane 内容 + scrollback(带属性,如 tmux `capture-pane -p -e -S -N`)喂进现有 RingBuffer;前端 `since` 游标重放契约(`ARCHITECTURE.md:231`)不变 |
102+
| 4 | 字节流续接 | 重连后 output 继续喂 `pumpLogs`→RingBuffer→广播;input → 按键注入;resize → 设窗口尺寸 |
103+
| 5 | Shutdown 语义反转 | 现在 reasonix/dsh 是显式杀(`app.go:707-709`);pty 要改为 **退出 = 全体客户端断开,会话侧保留** |
104+
| 6 | 前端零改动 | pty Kind 本就是 opaque Kind + Resize/SendInput/SubscribeOutput 扩展接口(`pty/driver.go:251,259,262`),换后端不动 WS/SSE/游标/心跳协议 |
105+
106+
另需说明 `PDEATHSIG` 的方向:它保证"父死子必须死"(且内核关闭 fd 先于
107+
PDEATHSIG 投递),与内核 hangup 现在扮演的角色相同——只能用来消灭孤儿,
108+
不能用来保活。保活必须移走 master,没有捷径。
109+
110+
## 5. 候选方案
111+
112+
### A. tmux(隔离 socket)— 推荐讨论主线
113+
114+
- 形态:`tmux -L mw-<dataHash>` 独立 socket(绝不碰用户自己的 tmux server);
115+
spawn = `new-session -d -s mw-<id> zsh -f -i`,150ms 后 `send-keys` 注入
116+
tag 命令(对齐 `pty/driver.go:139-144` 现有行为);runtime = control mode
117+
(`tmux -C attach`)attach,`%output` 喂现有管道;restart = Reattach =
118+
`has-session` 探活 + `capture-pane` 回放 + 重进 control mode。
119+
- 天然满足三条需求:**存活**(tmux server setsid、忽略 SIGHUP、不占任何终
120+
端,myworktree 生死只是客户端来去)、**可追溯**(session 命名 + `tmux ls`)、
121+
**可拉回**(控制模式重连 + scrollback 持久化,顺带解决"ring buffer 重启
122+
即丢")。
123+
- Reattach 形状与 `reasonix.Kind.Reattach`(`reasonix/kind.go:192`)一致,
124+
只是探活从 pid+TCP 换成 tmux 查询。框架这条路已跑通过一次。
125+
- 代价:新增二进制依赖;control-mode 协议解析(`%begin/%end/%error`、
126+
`%output`、会话通知)是最大工程面;按键/括号粘贴映射;tmux 对输出字节做
127+
了自身解释,个别 passthrough/sixel/OSC-8 特性与直连 pty 有差异。
128+
- 依赖管理:写稿时本开发机未安装 tmux(`which tmux` 为空)。需像 dsh 的
129+
`exec.LookPath` 预检一样处理缺失(结构化错误 + 前端引导),不建议保留
130+
in-process pty 做双后端 fallback(两套后端的维护成本高于一个预检)。
131+
132+
### B. 自研 terminal supervisor
133+
134+
每实例一个 setsid 守护进程持有 PTY,myworktree 经 unix socket 转发 I/O。形态
135+
上是 tmux 的最小子集,但 scrollback 持久化、多客户端、会话回收全部自踩一遍
136+
tmux 踩过的坑。仅当需要 tmux 给不了的深度集成(如 ring buffer 与实例状态强
137+
绑定)才考虑,否则不推荐。
138+
139+
### C. 保持现状 + 回收闭环(不改存活语义)
140+
141+
承认"终端随 daemon 生死"是设计语义,只补回收:注入 `MYWORKTREE_INSTANCE=<id>`
142+
env(`/proc/*/environ` 可反查归属);启动时对持久化 pid 做 starttime
143+
校验(防 pid 复用)后杀进程组;Shutdown 显式 `StopAllKind(pty)` 把逃逸口焊
144+
死;dsh-web 补 Reattach 或启动时强制清理。长期跑 agent 的需求走 dsh-web
145+
kind(detached + reverse proxy 模式)。
146+
147+
### D. systemd --user scope / cgroup(作为 A/C 的补充)
148+
149+
解决不了 master fd 问题,但给每实例独立 scope/cgroup:按名字批量杀、资源
150+
计量、崩溃后可寻回。是"可追溯 + 可回收"的 Linux 原生答案,与 A/C 正交。
151+
152+
## 6. "不中断"的边界
153+
154+
**能保证**(方案 A):zsh 本体(环境变量、cwd、历史)、前台 opencode TUI、
155+
其网络连接全部存活——终端里的 shell 不依赖 myworktree 的任何东西,这正是终
156+
端比 reasonix serve(需注入 env/token)更容易做存活的原因。myworktree 优雅
157+
退出与被 kill -9 在方案 A 下等价(都是摘除客户端)。
158+
159+
**不能保证**:
160+
- 无客户端期间窗口尺寸停在旧值(重连时统一 resize,ncurses 通常自愈);
161+
- control-mode 的 `%output` 是 tmux 解释渲染后的字节流,个别
162+
passthrough/sixel/OSC-8 特性与直连 pty 有细微差异;
163+
- 粘贴中途、半截输入等瞬时状态;
164+
- 依赖 myworktree 自身环境/连接的进程例外(终端场景不存在此问题);
165+
- 登出即杀全部用户进程的环境(systemd `KillUserProcesses=yes`)下 tmux
166+
server 同样活不了——那种场景退回现状行为,可接受。
167+
168+
## 7. 长期未回收风险(若允许存活)
169+
170+
1. **失控代理**:opencode cli 无人值守继续烧 token、改文件、动 git worktree,
171+
而用户以为"关了"。会话活着 ≠ 用户知道它活着。
172+
2. **日志账**:跨重启可回看就得把 ring buffer 落盘——项目刚因 10MB 文件截断
173+
造成 ~100,000× 写放大而删掉磁盘日志(`ARCHITECTURE.md:100-102`),不能
174+
重蹈覆辙。
175+
3. **单点与升级 skew**:supervisor/tmux server 成为单点;老会话被新版本
176+
attach 的协议兼容、resize/redaction 语义差异。
177+
4. **僵尸会话**:shell 已死但会话还在,Status 报 running 实则无人。
178+
5. **Stop 路径必须穿过持有者**:否则 `kill -9 myworktree` 后再没人管这棵
179+
树——detached 架构最经典的泄漏。
180+
6. **与 worktree 生命周期打架**:删 worktree 时里面有活进程 → git 删不掉、
181+
进程 hold 着已删除的 cwd 与软链凭证(reasonix 文档中
182+
"restart-then-delete 把持凭证的 serve 变孤儿"是缩小版)。
183+
7. **假 reattach**:pid 复用导致探活误判——pidfd 或 `/proc/<pid>/stat`
184+
starttime 校验是必配。
185+
8. **socket 安全**:隔离 socket 的目录权限(多用户机器必须私有化)。
186+
187+
## 8. 待决策分叉点(本文档不决策)
188+
189+
1. "原样"的边界:只要求活着的 shell + TUI + 当前屏幕(`capture-pane` 够
190+
用),还是包含完整 scrollback 历史(直接从持有者侧取,别让 myworktree 的
191+
RingBuffer/RAM 预算扛)?——决定回放源设计。
192+
2. tmux 是硬依赖 + 预检,还是保留 in-process pty 做 fallback(双后端)?
193+
3. Shutdown 改 detach 后,Stop/Delete/Restart 对会话侧的新语义(谁负责
194+
`kill-session`)。
195+
4. 孤儿回收策略:shell 已死的会话、用户手动 `kill-server` 后的状态漂移。
196+
5. C 方案(现状 + 闭环)是否作为 A 落地前的过渡step先做——尤其 dsh-web 的
197+
Reattach 缺口本身就是一个应当独立修复的缺陷。
198+
199+
## 9. 附注
200+
201+
- 写稿时开发环境未安装 tmux(`which tmux` 为空),依赖可用性是方案 A 的第
202+
一道 gate。
203+
- 本文档为纯讨论记录,不修改任何代码。

0 commit comments

Comments
 (0)