Skip to content

Commit 321ea5c

Browse files
committed
docs: add managed restart terminal preservation design
1 parent ccab025 commit 321ea5c

1 file changed

Lines changed: 335 additions & 0 deletions

File tree

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
# Managed Restart Terminal Preservation Design
2+
3+
> Date: 2026-05-15
4+
> Status: Draft for review
5+
> Scope: Make explicit `--restart` preserve active terminals and agent sessions long enough for the next server instance to reattach, while keeping `stop` and crash semantics unchanged
6+
7+
## 1. Overview
8+
9+
当前 `coder-studio serve --restart` / `coder-studio open --restart` 的行为与普通停止没有本质区别:CLI 会先删掉旧 managed server,旧 server 停机时会执行 `terminalMgr.shutdown()`,从而对所有 PTY 发送 `SIGTERM` 并清掉内存中的 ring buffer / snapshot。结果是:
10+
11+
- 不能在 Coder Studio 自己的终端里直接执行 Coder Studio 的主动重启
12+
- shell terminal 会被一起杀掉
13+
- agent session 会因为底层 terminal 消失而在新 server 启动后被 hydrate 为 `ended`
14+
15+
本设计的目标不是改变“服务停止会关闭所有进程”这一默认语义,而是只为显式 `--restart` 增加一个受控例外:让 terminal 和 agent session 在一个很短的重启窗口里暂存,等待新 server 实例接管;如果新实例没有在窗口内完成接管,仍然按“关闭所有进程”收尾。
16+
17+
## 2. Goals
18+
19+
- 只在显式 `--restart` 路径下保留 active shell terminal 和 agent session 对应的 PTY
20+
- 允许用户在 Coder Studio 自己的终端里触发 managed restart,而不把当前终端先杀掉
21+
- 让新 server 启动后恢复同一个 `terminalId` / `sessionId`,而不是新建新的 terminal / session
22+
- 复用现有 websocket 自动重连、`terminal.replay``terminal.snapshot` 恢复能力
23+
- 确保重启失败时不会留下无限期悬挂的孤儿 PTY
24+
25+
## 3. Non-Goals
26+
27+
- 不改变 `stop` 的当前语义;主动 stop 仍然关闭所有 PTY 和会话
28+
- 不改变进程崩溃、PM2 异常退出、机器重启时的默认行为;这些情况仍然应最终关闭 PTY
29+
- 不实现跨主机或跨机器的会话迁移
30+
- 不保证操作系统重启后的 session 恢复
31+
- 不把 tmux / screen 作为用户可见的新运行时依赖
32+
33+
## 4. Current Behavior
34+
35+
### 4.1 CLI restart path
36+
37+
`startManagedServer()` 在启动新 managed server 之前,会先删除旧 managed server。这个流程与主动 stop 的效果相同,都会先让旧 server 退出。
38+
39+
### 4.2 Server shutdown path
40+
41+
旧 server 退出时会调用 `terminalMgr.shutdown()`。该逻辑会对所有还活着的 terminal 发送 `SIGTERM`,然后立即释放本地 terminal 状态和 snapshot buffer。
42+
43+
### 4.3 Session hydrate path
44+
45+
新 server 启动后,`SessionManager.hydrate()` 只会保留仍然能在 `terminalMgr` 内存里找到、并且 `alive === true` 的 terminal。只要 terminal 不在内存里,原先 `running` / `idle` 的 session 最终都会被收敛成 `ended`
46+
47+
### 4.4 Frontend recovery baseline
48+
49+
前端已经具备 websocket 自动重连,以及 `terminal.replay` / `terminal.snapshot` 的恢复机制。也就是说,真正缺失的不是浏览器端重连,而是“server 重启期间 terminal 生命周期能否延续到下一实例”。
50+
51+
## 5. Constraints
52+
53+
- 旧 server 进程退出后,当前 `TerminalManager` 持有的 PTY 句柄无法直接被新 server 进程继承
54+
- 仅仅在 `--restart` 时跳过 `terminalMgr.shutdown()` 并不能解决问题,因为新 server 没有办法接回旧进程内存里的 PTY 对象
55+
- 只有显式 `--restart` 才能触发保留;普通 stop 或 crash 不能因为“终端还活着”而改变原有关闭语义
56+
- 重启窗口必须是有限时长;失败重启不能演变成永久保活
57+
- 现有 `session.state` 不能只靠“terminal 还活着”来推断,agent session 在 server 离线窗口里的输出仍然可能改变 `running` / `idle` 状态
58+
59+
## 6. Options Considered
60+
61+
### 6.1 Option A: Skip `terminalMgr.shutdown()` only for `--restart`
62+
63+
这个方案最小,但不可行。问题不在于“是否 kill”,而在于 PTY 对象当前完全属于旧 server 进程。一旦旧进程退出,新进程接不回这些 PTY,也接不回 ring buffer 与 snapshot。
64+
65+
### 6.2 Option B: Host terminals inside tmux / screen
66+
67+
这个方案可以快速验证“terminal 重启后仍然存在”的体验,但语义不符合本需求。tmux 更接近“即便 server crash 也继续保活”,而本需求要求只有显式 `--restart` 例外,其余 stop / crash 仍然关闭。它还会把 session 状态同步和 terminal 历史恢复复杂度转嫁给外部工具。
68+
69+
### 6.3 Option C: Introduce a dedicated PTY broker plus explicit restart intent
70+
71+
推荐方案。把 PTY 生命周期从 server 进程中拆出来,由独立 broker 持有 PTY、ring buffer 和 snapshot。旧 server 只在显式 `--restart` 时把 terminal 从“attached”转换到短时“preserved”,新 server 在 TTL 内重新 claim;普通 stop 和 crash 仍然由 broker 负责收尾并 kill PTY。
72+
73+
## 7. Chosen Design
74+
75+
采用 `restart intent + PTY broker + lease/TTL` 模型。
76+
77+
核心原则:
78+
79+
- PTY 的真实生命周期不再由 `TerminalManager` 进程内对象决定,而由 broker 决定
80+
- “保留 terminal” 不再等价于“旧 server 不 kill”,而等价于“旧 server 把 terminal lease 交给 broker 的短时 preserved 状态”
81+
- broker 在 owner 断开连接时默认 kill PTY;只有存在有效 preserve lease 时才短时保留
82+
- 新 server 必须在 listen 之前先完成 terminal reattach / hydrate,这样前端 websocket 恢复后看到的仍是同一组 terminal 和 session
83+
84+
## 8. Architecture
85+
86+
### 8.1 Restart intent
87+
88+
CLI 在显式 `--restart` 路径下,先写一个本地 `restart-intent` 文件,再停止旧 server、启动新 server。
89+
90+
推荐字段:
91+
92+
- `requestId`
93+
- `expectedServerInstanceId`
94+
- `createdAt`
95+
- `expiresAt`
96+
- `mode: "preserve_terminals"`
97+
98+
约束:
99+
100+
- 只有 `--restart` 写 intent;普通 `stop` 不写
101+
- intent 必须绑定旧 server 的 `serverInstanceId`,防止陈旧 intent 让后续无关退出误入 preserve 路径
102+
- intent 过期后无效;broker 和 server 都不得接受过期 intent
103+
104+
### 8.2 PTY broker
105+
106+
新增独立本地 broker 进程,负责持有:
107+
108+
- PTY 进程本身
109+
- terminal 元数据:`terminalId``workspaceId``kind``argv``cwd``cols``rows``title`
110+
- ring buffer
111+
- headless snapshot buffer
112+
- 序列号与 `lastOutputAt`
113+
- 当前 lease 状态
114+
115+
broker 仅提供本机 IPC,不开放远程网络访问。实现层可按平台封装为:
116+
117+
- POSIX:Unix domain socket
118+
- Windows:named pipe
119+
120+
Server 通过 broker client 与其通信;`TerminalManager` 从“直接拥有 PTY”改为“对 broker 的本地适配层”。
121+
122+
### 8.3 Terminal lease state machine
123+
124+
每个 terminal 都处于以下状态之一:
125+
126+
- `attached`
127+
归某个 `serverInstanceId` 所有;正常收发 output、write、resize、replay、snapshot
128+
- `preserved`
129+
仅在显式 `--restart` 触发;旧 owner 已退出或即将退出,terminal 保留到 `expiresAt`
130+
- `ended`
131+
PTY 已退出或被 broker 杀掉
132+
133+
状态迁移规则:
134+
135+
- `attached -> preserved`
136+
只有旧 server 在看到匹配自己的有效 restart intent 后,显式调用 `detachForRestart(requestId, ttlMs)` 才允许发生
137+
- `attached -> ended`
138+
主动 stop、terminal.close、workspace teardown、owner crash 且无有效 preserve lease 时进入
139+
- `preserved -> attached`
140+
新 server 在 TTL 内用同一个 `requestId` claim 成功
141+
- `preserved -> ended`
142+
TTL 到期、claim 失败清理、或 broker 检测到 terminal 本身已退出
143+
144+
### 8.4 Owner crash semantics
145+
146+
这是本设计最重要的边界之一。
147+
148+
broker 必须维护 owner 连接或 heartbeat。当 owner server 断开时:
149+
150+
- 如果 terminal 仍是普通 `attached`,broker 立即 kill PTY
151+
- 只有 terminal 已经被显式切到 `preserved`,broker 才允许它继续活到 `expiresAt`
152+
153+
这保证:
154+
155+
- 主动 `stop` 保持现状
156+
- 非预期 crash 保持现状
157+
- 只有显式 `--restart` 才有例外
158+
159+
### 8.5 TerminalManager responsibilities after refactor
160+
161+
`TerminalManager` 不再直接 `spawn()` 本地 PTY。它改为:
162+
163+
- `create(spec)` 时请求 broker 创建 terminal
164+
- 订阅 broker 输出事件并继续向 event bus 发 `terminal.output`
165+
- 通过 broker 执行 `write` / `resize` / `close` / `replay` / `snapshot`
166+
- 在 server 启动时从 broker `hydrateAttached()` / `claimPreserved()` 恢复 active terminals
167+
- 在普通 stop 时调用 broker close-all
168+
- 在 restart-preserve stop 时调用 broker detach-for-restart,而不是 close-all
169+
170+
### 8.6 SessionManager recovery model
171+
172+
仅仅把 terminal 接回来还不够,因为 agent session 在 server 离线期间可能继续输出,状态可能从 `running` 变成 `idle`
173+
174+
因此,新 server 在 claim 完 terminal 后,`SessionManager.hydrate()` 需要分两类处理:
175+
176+
- `shell terminal`
177+
只要求 terminal 仍然存活,前端恢复后可继续交互
178+
- `agent session`
179+
除了判断 terminal 是否存活,还必须恢复 PTY state detector 的上下文
180+
181+
推荐做法:
182+
183+
- broker 为每个 terminal 记录 `lastOutputAt` 和一段最近输出 tail
184+
- 新 server 为 hydrated agent session 重新创建 `PtyStateDetector`
185+
- 如果 session 原状态是 `running` / `starting`,则向 detector 回放 preserve 窗口内的输出或 recent tail
186+
- 如果窗口内无新输出,且 `now - lastOutputAt` 已超过 provider 的 `idleDebounceMs`,则可直接把 session 纠正为 `idle`
187+
- 如果 terminal 在 preserve 窗口内自然退出,则 session 仍按现有语义转为 `ended`
188+
189+
这一步的目标不是完美重放所有历史,而是保证 server 重启不会让一个已经闲置的 agent session 永久卡在 `running`
190+
191+
## 9. Lifecycle
192+
193+
### 9.1 Normal stop
194+
195+
1. CLI 执行 stop,不写 restart intent
196+
2. server 收到终止信号
197+
3. `TerminalManager` 走普通 shutdown 路径
198+
4. broker 关闭并 kill 所有 attached terminals
199+
5. session 按现有规则结束
200+
201+
### 9.2 Explicit `--restart`
202+
203+
1. CLI 读取当前 runtime,写入带 `expectedServerInstanceId` 的 restart intent
204+
2. CLI 停掉旧 managed server
205+
3. 旧 server 收到终止信号,检测到与自己匹配的有效 intent
206+
4. `TerminalManager` 不执行普通 shutdown,而是对 active terminals 调用 `detachForRestart(requestId, ttlMs)`
207+
5. broker 把 terminals 标记为 `preserved`
208+
6. 新 server 启动,连接 broker,并用 `requestId` claim preserved terminals
209+
7. 新 server 先 hydrate terminals,再 hydrate sessions,最后开始监听 HTTP / WS
210+
8. CLI 观察到新 runtime 就绪后清理 restart intent
211+
212+
### 9.3 Restart failure
213+
214+
1. CLI 已写 intent,旧 server 已 detach terminals
215+
2. 新 server 未在 TTL 内成功启动并 claim
216+
3. broker 在 `expiresAt` 后 kill preserved terminals
217+
4. 结果退化为“重启失败即关闭 terminal / session”
218+
219+
### 9.4 Unexpected crash
220+
221+
1. server 进程崩溃,没有机会执行 `detachForRestart()`
222+
2. broker 发现 owner 断开,terminal 仍为 `attached`
223+
3. broker 立即 kill terminals
224+
4. 行为与当前 crash 语义一致
225+
226+
## 10. Data and API Surface
227+
228+
### 10.1 Broker API surface
229+
230+
最小需要的 broker 操作包括:
231+
232+
- `createTerminal(spec, ownerServerInstanceId)`
233+
- `attachTerminal(terminalId, ownerServerInstanceId)`
234+
- `claimPreservedTerminals(requestId, newServerInstanceId)`
235+
- `detachForRestart(ownerServerInstanceId, requestId, ttlMs)`
236+
- `write(terminalId, bytes)`
237+
- `resize(terminalId, cols, rows)`
238+
- `replay(terminalId, lastSeq)`
239+
- `snapshot(terminalId)`
240+
- `close(terminalId)`
241+
- `closeAllForOwner(serverInstanceId)`
242+
- `subscribeOutput(ownerServerInstanceId)`
243+
244+
### 10.2 Persistence expectations
245+
246+
不要求把 broker 的 terminal 状态写入数据库。broker 是短生命周期的本地运行时组件,不是持久化存储层。
247+
248+
数据库仍然是:
249+
250+
- terminal / session 身份的持久化来源
251+
- workspace 与 session 关联关系的持久化来源
252+
253+
broker 提供的是跨 server 进程重启窗口的“运行时延续”,不是长期持久化。
254+
255+
## 11. Frontend Impact
256+
257+
前端改动应尽量小。
258+
259+
因为 websocket client 已经具备:
260+
261+
- 自动重连
262+
- reconnect 状态追踪
263+
- replay / snapshot 恢复
264+
265+
所以关键要求是:新 server 必须在对外 accept websocket 之前先完成 terminal/session hydrate。只要这点成立,前端通常不需要新增专门的“restart preserve”协议。
266+
267+
可选增强:
268+
269+
- 在 terminal 恢复期间维持现有 reconnect UI,不新增“session ended”误导文案
270+
- 若个别 terminal 在 claim 前短暂返回 `unknown`,前端可继续使用现有 reconnect/backoff 机制等待下一次恢复
271+
272+
## 12. Risks
273+
274+
### 12.1 Broker complexity
275+
276+
这会引入新的本地守护进程和 IPC 层,复杂度高于单进程 terminal manager。但不这样拆,就无法满足“旧 server 退出后,新 server 接回同一 PTY”的硬约束。
277+
278+
### 12.2 Session state drift
279+
280+
agent session 在 server 离线窗口里继续输出,会让 `running` / `idle` 状态恢复变复杂。如果不补 detector catch-up,terminal 看似保住了,但 session 状态会长期错误。
281+
282+
### 12.3 Stale intent or stale preserved terminals
283+
284+
必须用 `expectedServerInstanceId + requestId + expiresAt` 三重约束,避免陈旧 intent 误命中;broker 也必须在 TTL 到期后强制 kill preserved terminals,不能让它们无限存活。
285+
286+
## 13. Testing Strategy
287+
288+
### 13.1 Broker unit tests
289+
290+
- attached terminal 在 owner crash 时立即被 kill
291+
- preserved terminal 在 TTL 内不会被 kill
292+
- preserved terminal 在 TTL 到期后被 kill
293+
- stale / mismatched intent 无法触发 preserve
294+
295+
### 13.2 Server integration tests
296+
297+
- `stop` 仍然结束 shell terminal 和 agent session
298+
- `--restart` 可让 shell terminal 在新 server 启动后继续 write / replay / snapshot
299+
- `--restart` 可让 hydrated session 保持原 `sessionId` / `terminalId`
300+
- running agent session 在重启窗口结束后可被纠正回 `idle``ended`
301+
- restart 失败时,preserved terminal 会在 TTL 后被清理
302+
303+
### 13.3 Web recovery tests
304+
305+
- websocket reconnect 后,terminal panel 对 preserved terminal 不显示 ended 状态
306+
- reconnect 恢复仍走现有 replay / snapshot 流程
307+
- preserved terminal claim 失败时,前端最终表现为正常断开,而不是假恢复
308+
309+
## 14. Rollout Plan
310+
311+
建议分两阶段落地:
312+
313+
### Phase 1: Shell-first preservation
314+
315+
- 引入 broker、restart intent、lease/TTL
316+
- 先让 shell terminal 在 `--restart` 后可继续存活和恢复
317+
- 验证 stop/crash 语义未变
318+
319+
### Phase 2: Agent session state recovery
320+
321+
- 补齐 `PtyStateDetector` 的 restart catch-up 逻辑
322+
- 让 preserved agent session 在新 server 启动后恢复正确的 `running` / `idle` / `ended` 状态
323+
324+
分阶段的原因是:shell continuity 解决的是“能不能在自己的终端里重启自己”,而 agent session state recovery 解决的是“恢复后状态是否仍然正确”。两者相关,但风险和验证面不同。
325+
326+
## 15. Final Decision
327+
328+
本设计明确采纳以下产品语义:
329+
330+
- 只有显式 `--restart` 会临时保留 terminals / sessions
331+
- 主动 `stop` 仍然关闭所有进程
332+
- 非预期 crash 仍然关闭所有进程
333+
- `--restart` 失败时,TTL 到期后也仍然关闭所有进程
334+
335+
换句话说,本次不是把 Coder Studio 改造成“terminal 永久托管器”,而只是给 managed restart 增加一个严格受控、自动回收的短时保留窗口。

0 commit comments

Comments
 (0)