@co0ontty/wand 4.72.1 → 4.72.2-beta.g52f54af

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,902 @@
1
+ import { lstatSync, readFileSync } from "node:fs";
2
+ import net from "node:net";
3
+ import process from "node:process";
4
+ import { getErrorMessage } from "./error-utils.js";
5
+ import { appendWindow, PTY_OUTPUT_MAX_SIZE } from "./pty-text-utils.js";
6
+ import { MAX_FRAME_BYTES, RENDER_PROTOCOL_VERSION, encodeRenderFrame, renderPaths, } from "./render-protocol.js";
7
+ import { appendTerminalChunkWindow, } from "./terminal-host.js";
8
+ const RECONNECT_INITIAL_MS = 500;
9
+ const RECONNECT_MAX_MS = 10_000;
10
+ /**
11
+ * 鉴权被同一个活着的 daemon 拒过之后的重试间隔。
12
+ *
13
+ * 这种情况(连接被接受、请求发出后被静默关闭)不是网络抖动:同一个 socket 路径上
14
+ * 有一个持有**别的** token 的 Render。按 10s 重试只是给自己刷日志,所以拉开到一分钟;
15
+ * 每次重试仍会重读 token 文件,daemon 重启(轮换 token)或文件被修正后会自愈。
16
+ */
17
+ const AUTH_FAILURE_RETRY_MS = 60_000;
18
+ const REQUEST_TIMEOUT_MS = 10_000;
19
+ /** 未挂上 listener 时的缓存上限,与 legacy 客户端保持一致。 */
20
+ const MAX_PENDING_EVENTS = 512;
21
+ /**
22
+ * 鉴权层面的失败(与网络抖动区分)。
23
+ *
24
+ * 特征:连接建立成功、hello 发出后对端**一个字节都没回**就关闭 —— Render 对
25
+ * token 不符/协议不符的处理就是直接 `close()`。单独一个类型是为了让重连策略
26
+ * 能把「同一把废 token」和「daemon 不在了」区分开,而不是一律 10s 重试。
27
+ */
28
+ export class RenderAuthError extends Error {
29
+ constructor(message) {
30
+ super(message);
31
+ this.name = "RenderAuthError";
32
+ }
33
+ }
34
+ /**
35
+ * 远端 PTY 句柄。Render 可能在这个对象挂上 listener 之前就推来 data/exit
36
+ * (大输出场景几乎必然发生),所以先做有界缓存,挂上后按序补发。
37
+ */
38
+ class RemoteRenderProcess {
39
+ sessionId;
40
+ incarnationId;
41
+ pid;
42
+ client;
43
+ dataListeners = new Set();
44
+ exitListeners = new Set();
45
+ undeliveredEvents = [];
46
+ pendingExit = null;
47
+ constructor(sessionId, incarnationId, pid, client) {
48
+ this.sessionId = sessionId;
49
+ this.incarnationId = incarnationId;
50
+ this.pid = pid;
51
+ this.client = client;
52
+ }
53
+ write(data) {
54
+ this.client.fireOperation(this.sessionId, "write", { sessionId: this.sessionId, data });
55
+ }
56
+ resize(cols, rows) {
57
+ // daemon 的 data 事件不带尺寸,inventory 又是 attach() 的唯一来源:
58
+ // 不同步这一步,attach() 会一直返回 resize 之前的 cols/rows。
59
+ this.client.noteTerminalResize(this.sessionId, cols, rows);
60
+ this.client.fireOperation(this.sessionId, "resize", { sessionId: this.sessionId, cols, rows });
61
+ }
62
+ kill(signal) {
63
+ this.client.fireOperation(this.sessionId, "kill", { sessionId: this.sessionId, signal });
64
+ }
65
+ onData(listener) {
66
+ this.dataListeners.add(listener);
67
+ if (this.undeliveredEvents.length > 0) {
68
+ const pending = this.undeliveredEvents;
69
+ this.undeliveredEvents = [];
70
+ queueMicrotask(() => {
71
+ if (!this.dataListeners.has(listener))
72
+ return;
73
+ for (const event of pending)
74
+ listener(event);
75
+ });
76
+ }
77
+ return { dispose: () => this.dataListeners.delete(listener) };
78
+ }
79
+ onExit(listener) {
80
+ this.exitListeners.add(listener);
81
+ const pending = this.pendingExit;
82
+ if (pending) {
83
+ // 退出事件只发生一次,但可能早于 listener 到达;补发语义必须与 data 一致。
84
+ queueMicrotask(() => {
85
+ if (this.exitListeners.has(listener))
86
+ listener(pending);
87
+ });
88
+ }
89
+ return { dispose: () => this.exitListeners.delete(listener) };
90
+ }
91
+ acceptData(event) {
92
+ if (this.dataListeners.size === 0) {
93
+ this.undeliveredEvents = appendTerminalChunkWindow(this.undeliveredEvents, event);
94
+ return;
95
+ }
96
+ this.emitData(event);
97
+ }
98
+ acceptExit(event) {
99
+ this.pendingExit = event;
100
+ for (const listener of Array.from(this.exitListeners))
101
+ listener(event);
102
+ }
103
+ emitData(event) {
104
+ for (const listener of Array.from(this.dataListeners))
105
+ listener(event);
106
+ }
107
+ }
108
+ /**
109
+ * Render(常驻 PTY 持有者)的 Node 客户端。
110
+ *
111
+ * 与 legacy `TerminalDaemonClient` 的关键差异:**这里只有 PTY**,没有结构化运行;
112
+ * 另外 `disconnect()` 只解绑 socket,Render 进程与其 PTY 全部保留 —— 这正是
113
+ * Server 因 `npm update` 重启而 PTY 不丢的前提。
114
+ */
115
+ export class RenderDaemonClient {
116
+ socketPath;
117
+ token;
118
+ tokenPath;
119
+ persistent = true;
120
+ socket = null;
121
+ nextRequestId = 1;
122
+ pending = new Map();
123
+ inventory = new Map();
124
+ handles = new Map();
125
+ pendingEvents = new Map();
126
+ // socket 帧解码状态:长度前缀最多 4 字节;正文按到达顺序分片,避免大帧反复整块拷贝。
127
+ headerParts = [];
128
+ headerBytes = 0;
129
+ bodyParts = [];
130
+ bodyBytes = 0;
131
+ frameLength = null;
132
+ disposed = false;
133
+ reconnectTimer = null;
134
+ reconnectDelayMs = RECONNECT_INITIAL_MS;
135
+ reconnectFailureLogged = false;
136
+ offlineOperationLogged = false;
137
+ /** 协议版本不匹配是终态:重试无意义,也不允许降级运行。 */
138
+ fatalProtocolError = null;
139
+ daemonVersion = "";
140
+ /** 最近一次 connect 时实际使用的凭据。 */
141
+ activeToken = "";
142
+ /** 最后一次成功读到的凭据;token 文件暂时读不到时用它,避免重连因一次 IO 失败直接放弃。 */
143
+ lastKnownToken = "";
144
+ /** 已经因为「读不到 token 文件」告警过,避免每次重连都刷一行。 */
145
+ tokenReadFailureLogged = false;
146
+ /** 当前 socket 上已解出的帧数,用来区分「未鉴权被直接关闭」与网络错误。 */
147
+ framesOnCurrentSocket = 0;
148
+ constructor(socketPath, token,
149
+ /**
150
+ * token 文件路径。给了它就在**每次 connect 时重读**:Render 崩溃/升级重启后
151
+ * 会 `generate_token()` 轮换 token,只缓存构造期的那一把会让重连永远鉴权失败
152
+ * (现象是 `createOrAttach` 全部失败、错误只显示 unavailable,旧会话被永久报成 running)。
153
+ */
154
+ tokenPath = null) {
155
+ this.socketPath = socketPath;
156
+ this.token = token;
157
+ this.tokenPath = tokenPath;
158
+ this.lastKnownToken = token;
159
+ }
160
+ get version() {
161
+ return this.daemonVersion;
162
+ }
163
+ async connect() {
164
+ if (this.disposed)
165
+ throw new Error("Render client disposed");
166
+ if (this.socket && !this.socket.destroyed)
167
+ return;
168
+ // 先校验对端再送 token:/tmp 是全局可写的,任何人都能抢注同名 socket 路径,
169
+ // 抢注者拿到 token 就等于拿到该 config 全部 PTY 的完整读写权限。
170
+ assertSocketOwnership(this.socketPath);
171
+ this.activeToken = this.readToken();
172
+ const socket = net.createConnection(this.socketPath);
173
+ this.framesOnCurrentSocket = 0;
174
+ socket.setNoDelay(true);
175
+ this.socket = socket;
176
+ await new Promise((resolve, reject) => {
177
+ const onConnect = () => { cleanup(); resolve(); };
178
+ const onError = (error) => { cleanup(); reject(error); };
179
+ const cleanup = () => {
180
+ socket.off("connect", onConnect);
181
+ socket.off("error", onError);
182
+ };
183
+ socket.once("connect", onConnect);
184
+ socket.once("error", onError);
185
+ });
186
+ // 新 socket 从干净状态开始解码:上一条连接留下的半帧不能污染这一条。
187
+ this.resetFrameDecoder();
188
+ this.offlineOperationLogged = false;
189
+ socket.on("data", (data) => this.consume(data));
190
+ // 传 socket 引用:旧连接的迟到 close/error 不能影响当前连接上的在途请求。
191
+ socket.on("close", () => this.handleDisconnect(socket));
192
+ socket.on("error", (error) => {
193
+ this.rejectPending(error instanceof Error ? error : new Error(String(error)));
194
+ this.handleDisconnect(socket);
195
+ });
196
+ try {
197
+ const hello = this.parseHello(await this.request("hello", undefined, "hello"));
198
+ if (hello.protocolVersion !== RENDER_PROTOCOL_VERSION) {
199
+ const fatal = new Error(`Render protocol version mismatch: daemon reports ${hello.protocolVersion}, Server requires ${RENDER_PROTOCOL_VERSION}. ` +
200
+ "Refusing to run degraded; upgrade the Render binary or switch render.engine back to legacy.");
201
+ this.fatalProtocolError = fatal;
202
+ throw fatal;
203
+ }
204
+ this.daemonVersion = hello.version;
205
+ const previous = new Map(this.inventory);
206
+ const sessions = await this.listSessions();
207
+ this.inventory.clear();
208
+ for (const state of sessions)
209
+ this.inventory.set(state.sessionId, state);
210
+ if (previous.size > 0) {
211
+ this.reconcileAfterReconnect(previous);
212
+ process.stderr.write("[wand] Reconnected to Render; reconciled PTY inventory.\n");
213
+ }
214
+ }
215
+ catch (error) {
216
+ if (!this.disposed) {
217
+ try {
218
+ socket.destroy();
219
+ }
220
+ catch { /* best-effort cleanup */ }
221
+ this.handleDisconnect();
222
+ }
223
+ throw this.describeConnectFailure(error);
224
+ }
225
+ }
226
+ /**
227
+ * 对端一个字节都没回就断开:daemon 对 token/协议不符的处理是直接 `close()`
228
+ * (`wand-renderd` 的 `server.rs`),所以这是鉴权/版本问题的特征,不是网络抖动。
229
+ * 归类为 `RenderAuthError`,由重连策略决定「别再 10s 一次拿同一把废 token 试」。
230
+ */
231
+ describeConnectFailure(error) {
232
+ const message = getErrorMessage(error);
233
+ if (this.framesOnCurrentSocket > 0 || !/disconnected|unavailable/i.test(message)) {
234
+ return error instanceof Error ? error : new Error(message);
235
+ }
236
+ return new RenderAuthError(`Render closed the connection without answering the hello (${message}); ` +
237
+ "the daemon is likely holding a different token or protocol version than " +
238
+ `${this.tokenPath ?? "the configured token"}. Refusing to retry the same credential blindly.`);
239
+ }
240
+ /**
241
+ * 读取当前凭据:每次都用 token 文件里的**最新**值(daemon 重启会轮换)。
242
+ * 文件暂时读不到(渲染器正在退出、路径还没建)不是致命错误,回落到上一次成功
243
+ * 读到的值并告警一次;下一次连接会重试读文件。
244
+ */
245
+ readToken() {
246
+ if (!this.tokenPath)
247
+ return this.token;
248
+ try {
249
+ const token = readFileSync(this.tokenPath, "utf8").trim();
250
+ if (token) {
251
+ this.lastKnownToken = token;
252
+ this.tokenReadFailureLogged = false;
253
+ return token;
254
+ }
255
+ this.warnTokenFileUnusable("is empty");
256
+ }
257
+ catch (error) {
258
+ this.warnTokenFileUnusable(getErrorMessage(error));
259
+ }
260
+ if (!this.lastKnownToken) {
261
+ throw new Error(`Render token file ${this.tokenPath} cannot be read and no previous credential is known; refusing to connect anonymously.`);
262
+ }
263
+ return this.lastKnownToken;
264
+ }
265
+ warnTokenFileUnusable(reason) {
266
+ if (this.tokenReadFailureLogged)
267
+ return;
268
+ this.tokenReadFailureLogged = true;
269
+ process.stderr.write(`[wand] Render token file ${this.tokenPath} ${reason}; reusing the last known credential and retrying the file on the next attempt.\n`);
270
+ }
271
+ /** resize 走的是异步 RPC,但 inventory 必须立刻反映新尺寸(见 RemoteRenderProcess.resize)。 */
272
+ noteTerminalResize(sessionId, cols, rows) {
273
+ const state = this.inventory.get(sessionId);
274
+ if (!state)
275
+ return;
276
+ state.cols = cols;
277
+ state.rows = rows;
278
+ }
279
+ attach(sessionId, afterSeq = 0) {
280
+ const state = this.inventory.get(sessionId);
281
+ if (!state)
282
+ return null;
283
+ return this.resultFromState(state, false, afterSeq);
284
+ }
285
+ async createOrAttach(request, afterSeq = 0) {
286
+ const existing = this.inventory.get(request.sessionId);
287
+ if (existing)
288
+ return this.resultFromState(existing, false, afterSeq);
289
+ const params = {
290
+ sessionId: request.sessionId,
291
+ file: request.file,
292
+ args: request.args,
293
+ cwd: request.cwd,
294
+ env: toRenderEnv(request.env),
295
+ name: request.name,
296
+ cols: request.cols,
297
+ rows: request.rows,
298
+ ...(request.launchMarkerToken ? { launchMarkerToken: request.launchMarkerToken } : {}),
299
+ afterSeq,
300
+ };
301
+ const payload = await this.request("createOrAttach", { ...params });
302
+ const state = normalizeRenderSessionState(payload?.state);
303
+ if (!state) {
304
+ throw new Error(`Render returned an unusable createOrAttach payload for session ${request.sessionId}; refusing to guess the session state.`);
305
+ }
306
+ this.inventory.set(state.sessionId, state);
307
+ return this.resultFromState(state, payload?.isNew === true, afterSeq);
308
+ }
309
+ forget(sessionId) {
310
+ this.inventory.delete(sessionId);
311
+ this.handles.delete(sessionId);
312
+ this.pendingEvents.delete(sessionId);
313
+ this.fireOperation(sessionId, "forget", { sessionId });
314
+ }
315
+ /**
316
+ * 只解绑:关闭 socket、清掉内存句柄,**Render 进程与它的所有 PTY 都保持运行**。
317
+ * 这是 Server / Render 分离的核心 —— 下一次启动 attach 回来仍拿到同一批会话。
318
+ */
319
+ disconnect() {
320
+ this.disposed = true;
321
+ if (this.reconnectTimer) {
322
+ clearTimeout(this.reconnectTimer);
323
+ this.reconnectTimer = null;
324
+ }
325
+ const socket = this.socket;
326
+ this.socket = null;
327
+ if (socket && !socket.destroyed)
328
+ socket.destroy();
329
+ this.rejectPending(new Error("Render client disposed"));
330
+ this.handles.clear();
331
+ this.pendingEvents.clear();
332
+ // inventory 只保留到本进程结束:它是内存快照,不写盘、不影响 Render。
333
+ }
334
+ /**
335
+ * Best-effort 操作:Render 不可用时打 stderr,绝不把异常抛回调用方,
336
+ * 否则一次 PTY 抖动会顺着 ProcessManager 把整个会话路径打断。
337
+ */
338
+ fireOperation(sessionId, method, params) {
339
+ void this.request(method, params).catch((error) => this.reportOperationError(sessionId, method, error));
340
+ }
341
+ reportOperationError(sessionId, method, error) {
342
+ if (!this.socket || this.socket.destroyed) {
343
+ // socket 断开时每个输入包都会失败,只提示一次,避免刷满日志。
344
+ if (this.offlineOperationLogged)
345
+ return;
346
+ this.offlineOperationLogged = true;
347
+ process.stderr.write(`[wand] Render 未连接,${method} ${sessionId} 失败(后续同类失败不再重复提示;PTY 仍在 Render 侧运行)\n`);
348
+ return;
349
+ }
350
+ process.stderr.write(`[wand] Render ${method} failed for ${sessionId}: ${getErrorMessage(error)}\n`);
351
+ }
352
+ async listSessions() {
353
+ const result = await this.request("list");
354
+ const raw = result?.sessions;
355
+ if (!Array.isArray(raw)) {
356
+ throw new Error(`Render list returned ${raw === undefined ? "no sessions array" : typeof raw}; refusing to adopt an unknown inventory.`);
357
+ }
358
+ const states = [];
359
+ for (const entry of raw) {
360
+ const state = normalizeRenderSessionState(entry);
361
+ if (state)
362
+ states.push(state);
363
+ }
364
+ return states;
365
+ }
366
+ parseHello(value) {
367
+ if (!value || typeof value !== "object") {
368
+ throw new Error("Render hello returned an unusable payload; refusing to adopt this daemon.");
369
+ }
370
+ const record = value;
371
+ if (typeof record.protocolVersion !== "number") {
372
+ throw new Error("Render hello did not report protocolVersion; refusing to adopt this daemon.");
373
+ }
374
+ return {
375
+ version: typeof record.version === "string" ? record.version : "",
376
+ protocolVersion: record.protocolVersion,
377
+ pid: typeof record.pid === "number" ? record.pid : 0,
378
+ startedAt: typeof record.startedAt === "string" ? record.startedAt : "",
379
+ sessions: typeof record.sessions === "number" ? record.sessions : 0,
380
+ };
381
+ }
382
+ async request(method, params,
383
+ // hello 是唯一允许在 fatalProtocolError 之后仍尝试的请求:它正是判定该错误的地方。
384
+ bypassFatalGuard) {
385
+ if (this.fatalProtocolError && bypassFatalGuard !== "hello")
386
+ throw this.fatalProtocolError;
387
+ const socket = this.socket;
388
+ if (!socket || socket.destroyed || !socket.writable)
389
+ throw new Error("Render is unavailable");
390
+ const id = this.nextRequestId++;
391
+ const request = {
392
+ id,
393
+ token: this.activeToken || this.token,
394
+ protocolVersion: RENDER_PROTOCOL_VERSION,
395
+ method,
396
+ params,
397
+ };
398
+ return new Promise((resolve, reject) => {
399
+ const timer = setTimeout(() => {
400
+ this.pending.delete(id);
401
+ reject(new Error(`Render ${method} timed out`));
402
+ }, REQUEST_TIMEOUT_MS);
403
+ timer.unref?.();
404
+ this.pending.set(id, { method, resolve, reject, timer });
405
+ socket.write(encodeRenderFrame(request));
406
+ });
407
+ }
408
+ consume(chunk) {
409
+ // 逐帧解码:大帧(list 的 replay 可能几十 MB)必须以分片累积,
410
+ // 每次 socket read 都整块 concat 会退化成 O(n²) 拷贝。
411
+ let offset = 0;
412
+ while (offset < chunk.length) {
413
+ if (this.frameLength === null) {
414
+ const take = Math.min(4 - this.headerBytes, chunk.length - offset);
415
+ this.headerParts.push(chunk.subarray(offset, offset + take));
416
+ this.headerBytes += take;
417
+ offset += take;
418
+ if (this.headerBytes < 4)
419
+ return;
420
+ const header = this.headerParts.length === 1
421
+ ? this.headerParts[0]
422
+ : Buffer.concat(this.headerParts, 4);
423
+ this.headerParts = [];
424
+ this.headerBytes = 0;
425
+ const length = header.readUInt32BE(0);
426
+ if (length > MAX_FRAME_BYTES) {
427
+ process.stderr.write(`[wand] Render frame length ${length} exceeds the ${MAX_FRAME_BYTES} byte limit; closing the connection.\n`);
428
+ this.socket?.destroy();
429
+ return;
430
+ }
431
+ this.frameLength = length;
432
+ this.bodyParts = [];
433
+ this.bodyBytes = 0;
434
+ continue;
435
+ }
436
+ const take = Math.min(this.frameLength - this.bodyBytes, chunk.length - offset);
437
+ this.bodyParts.push(chunk.subarray(offset, offset + take));
438
+ this.bodyBytes += take;
439
+ offset += take;
440
+ if (this.bodyBytes < this.frameLength)
441
+ return;
442
+ const body = this.bodyParts.length === 1
443
+ ? this.bodyParts[0]
444
+ : Buffer.concat(this.bodyParts, this.bodyBytes);
445
+ this.frameLength = null;
446
+ this.bodyParts = [];
447
+ this.bodyBytes = 0;
448
+ this.handleFrame(body);
449
+ }
450
+ }
451
+ resetFrameDecoder() {
452
+ this.headerParts = [];
453
+ this.headerBytes = 0;
454
+ this.bodyParts = [];
455
+ this.bodyBytes = 0;
456
+ this.frameLength = null;
457
+ }
458
+ handleFrame(body) {
459
+ if (body.length === 0)
460
+ return;
461
+ this.framesOnCurrentSocket += 1;
462
+ let message;
463
+ try {
464
+ message = JSON.parse(body.toString("utf8"));
465
+ }
466
+ catch (error) {
467
+ process.stderr.write(`[wand] Render sent an undecodable frame: ${getErrorMessage(error)}\n`);
468
+ return;
469
+ }
470
+ if (!message || typeof message !== "object")
471
+ return;
472
+ const record = message;
473
+ if (record.event === undefined) {
474
+ this.handleResponse(message);
475
+ return;
476
+ }
477
+ this.routeEvent(message);
478
+ }
479
+ handleResponse(message) {
480
+ if (typeof message.id !== "number")
481
+ return;
482
+ const pending = this.pending.get(message.id);
483
+ if (!pending)
484
+ return;
485
+ this.pending.delete(message.id);
486
+ clearTimeout(pending.timer);
487
+ if (message.ok) {
488
+ pending.resolve(message.result);
489
+ return;
490
+ }
491
+ const code = message.error?.code ?? "unknown";
492
+ const detail = message.error?.message ?? "";
493
+ pending.reject(new Error(`Render ${pending.method} failed (${code})${detail ? `: ${detail}` : ""}`));
494
+ }
495
+ routeEvent(event) {
496
+ if (event.event === "reconcile") {
497
+ this.handleReconcileEvent(event.sessionIds);
498
+ return;
499
+ }
500
+ const sessionId = typeof event.sessionId === "string" ? event.sessionId : "";
501
+ const incarnationId = typeof event.incarnationId === "string" ? event.incarnationId : "";
502
+ if (!sessionId || !incarnationId) {
503
+ process.stderr.write("[wand] Render sent an event without sessionId/incarnationId; ignoring it.\n");
504
+ return;
505
+ }
506
+ const state = this.inventory.get(sessionId);
507
+ if (state && state.incarnationId === incarnationId) {
508
+ if (event.event === "data" && typeof event.data === "string" && typeof event.seq === "number") {
509
+ state.seq = Math.max(state.seq, event.seq);
510
+ state.output = appendWindow(state.output, event.data, PTY_OUTPUT_MAX_SIZE);
511
+ state.chunks = appendTerminalChunkWindow(state.chunks, { data: event.data, seq: event.seq });
512
+ }
513
+ else if (event.event === "exit") {
514
+ state.status = "exited";
515
+ state.exitCode = event.exitCode ?? -1;
516
+ }
517
+ }
518
+ const handle = this.handles.get(sessionId);
519
+ if (!handle || handle.incarnationId !== incarnationId) {
520
+ // 事件可能早于 createOrAttach 的响应,先缓存,句柄建立后再按 seq 过滤补发。
521
+ const buffered = this.pendingEvents.get(sessionId) ?? [];
522
+ buffered.push(event);
523
+ this.pendingEvents.set(sessionId, buffered.slice(-MAX_PENDING_EVENTS));
524
+ return;
525
+ }
526
+ if (event.event === "data" && typeof event.data === "string" && typeof event.seq === "number") {
527
+ handle.acceptData({ data: event.data, seq: event.seq });
528
+ }
529
+ else if (event.event === "exit") {
530
+ handle.acceptExit({ exitCode: event.exitCode ?? -1, signal: event.signal ?? undefined });
531
+ this.handles.delete(sessionId);
532
+ }
533
+ }
534
+ /**
535
+ * Render 主动告知当前会话集合(例如自身清掉了已 forget 的会话)。不在
536
+ * 集合里的会话要给既有句柄补一个退出事件,否则 ProcessManager 会永远停在 running。
537
+ */
538
+ handleReconcileEvent(sessionIds) {
539
+ if (!Array.isArray(sessionIds)) {
540
+ process.stderr.write("[wand] Render sent a reconcile event without a sessionIds array; ignoring it.\n");
541
+ return;
542
+ }
543
+ const live = new Set(sessionIds.filter((id) => typeof id === "string"));
544
+ for (const sessionId of Array.from(this.inventory.keys())) {
545
+ if (!live.has(sessionId))
546
+ this.inventory.delete(sessionId);
547
+ }
548
+ for (const sessionId of Array.from(this.handles.keys())) {
549
+ if (live.has(sessionId))
550
+ continue;
551
+ const handle = this.handles.get(sessionId);
552
+ this.handles.delete(sessionId);
553
+ handle.acceptExit({ exitCode: -1 });
554
+ }
555
+ }
556
+ /**
557
+ * Socket 断开后的重连。不做重连的话,Render 重启一次所有
558
+ * RemoteRenderProcess 就会静默失效,而 ProcessManager 仍显示 running。
559
+ */
560
+ handleDisconnect(origin) {
561
+ if (origin && this.socket !== origin)
562
+ return;
563
+ if (this.socket?.destroyed)
564
+ this.socket = null;
565
+ this.rejectPending(new Error("Render disconnected"));
566
+ if (this.disposed || this.fatalProtocolError)
567
+ return;
568
+ this.scheduleReconnect();
569
+ }
570
+ /** 被同一个凭据反复拒绝时置位:下一次重连改用长退避(见 `scheduleReconnect`)。 */
571
+ authBackoff = false;
572
+ /**
573
+ * 安排下一次重连(已有定时器时不重排:一次失败可能会从 close/error 两条路径
574
+ * 各调一次,重排会把退避倍率叠成指数增长)。需要**覆盖**已有定时器的场景
575
+ * (鉴权失败要把 10s 换成 60s)由调用方显式清掉再调。
576
+ */
577
+ scheduleReconnect() {
578
+ if (this.disposed || this.reconnectTimer)
579
+ return;
580
+ const delayMs = this.authBackoff ? AUTH_FAILURE_RETRY_MS : this.reconnectDelayMs;
581
+ this.reconnectTimer = setTimeout(() => {
582
+ this.reconnectTimer = null;
583
+ void this.tryReconnect();
584
+ }, delayMs);
585
+ this.reconnectTimer.unref?.();
586
+ if (!this.authBackoff) {
587
+ this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, RECONNECT_MAX_MS);
588
+ }
589
+ }
590
+ async tryReconnect() {
591
+ if (this.disposed)
592
+ return;
593
+ try {
594
+ await this.connect();
595
+ this.reconnectDelayMs = RECONNECT_INITIAL_MS;
596
+ this.reconnectFailureLogged = false;
597
+ this.authBackoff = false;
598
+ }
599
+ catch (error) {
600
+ if (this.disposed)
601
+ return;
602
+ const stalled = this.noteReconnectFailure(error);
603
+ if (stalled && this.reconnectTimer) {
604
+ // 断线路径已经按普通退避排过一次;用 60s 的长退避覆盖它。
605
+ clearTimeout(this.reconnectTimer);
606
+ this.reconnectTimer = null;
607
+ }
608
+ this.scheduleReconnect();
609
+ }
610
+ }
611
+ /**
612
+ * 重连失败的日志与「同一把废 token」判定。
613
+ *
614
+ * 判定依据:刚刚用掉的那把凭据(`activeToken`,每次连接开头从 token 文件重读)
615
+ * 与文件里**现在**这一把相同 → 再试同一个值不可能成功(对端持有的是别的 token,
616
+ * 多来自 `-c` 指到了另一个 config 或有人在手工改 token 文件),摆成 60s 长退避。
617
+ * 文件已经变了就按普通退避重试 —— 那正是 daemon 重启轮换 token 后的自愈路径。
618
+ * @returns 是否处于「同一把废凭据」状态(调用方据此换长退避)
619
+ */
620
+ noteReconnectFailure(error) {
621
+ if (!(error instanceof RenderAuthError)) {
622
+ this.authBackoff = false;
623
+ if (!this.reconnectFailureLogged) {
624
+ this.reconnectFailureLogged = true;
625
+ process.stderr.write(`[wand] Render reconnect failed: ${getErrorMessage(error)}\n`);
626
+ }
627
+ return false;
628
+ }
629
+ const current = this.currentTokenFileValue();
630
+ const stalled = current !== null && current === this.activeToken;
631
+ this.authBackoff = stalled;
632
+ if (!this.reconnectFailureLogged) {
633
+ this.reconnectFailureLogged = true;
634
+ process.stderr.write(`[wand] Render rejected the credential (${error.message})` +
635
+ `${stalled ? ` — retrying every ${AUTH_FAILURE_RETRY_MS / 1000}s instead of ${RECONNECT_MAX_MS / 1000}s until the daemon restarts` : ""}; ` +
636
+ "the token file is re-read on every attempt.\n");
637
+ }
638
+ return stalled;
639
+ }
640
+ /** 只读 token 文件当前值(不改状态);读不到返回 null。 */
641
+ currentTokenFileValue() {
642
+ if (!this.tokenPath)
643
+ return null;
644
+ try {
645
+ return readFileSync(this.tokenPath, "utf8").trim() || null;
646
+ }
647
+ catch {
648
+ return null;
649
+ }
650
+ }
651
+ /**
652
+ * 断线期间 Render 可能新建/结束会话。用断线前的 inventory 与新的 `list` 对账:
653
+ * 活着的补发缺口 chunk;消失或已退出的给句柄补合成退出。
654
+ */
655
+ reconcileAfterReconnect(previous) {
656
+ for (const [sessionId, oldState] of previous) {
657
+ const current = this.inventory.get(sessionId);
658
+ const handle = this.handles.get(sessionId);
659
+ const handleMatches = !!handle && handle.incarnationId === oldState.incarnationId;
660
+ const stillRunning = !!current
661
+ && current.incarnationId === oldState.incarnationId
662
+ && current.status === "running";
663
+ if (stillRunning && handleMatches) {
664
+ for (const chunk of current.chunks) {
665
+ if (chunk.seq > oldState.seq)
666
+ handle.acceptData(chunk);
667
+ }
668
+ continue;
669
+ }
670
+ const exitedOnRender = !!current
671
+ && current.incarnationId === oldState.incarnationId
672
+ && current.status === "exited";
673
+ const exitCode = exitedOnRender ? current.exitCode ?? -1 : -1;
674
+ this.handles.delete(sessionId);
675
+ if (handleMatches)
676
+ handle.acceptExit({ exitCode });
677
+ }
678
+ }
679
+ resultFromState(state, isNew, afterSeq) {
680
+ let process = this.handles.get(state.sessionId) ?? null;
681
+ if (state.status === "running" && (!process || process.incarnationId !== state.incarnationId)) {
682
+ process = new RemoteRenderProcess(state.sessionId, state.incarnationId, state.pid, this);
683
+ this.handles.set(state.sessionId, process);
684
+ const buffered = this.pendingEvents.get(state.sessionId) ?? [];
685
+ this.pendingEvents.delete(state.sessionId);
686
+ for (const event of buffered) {
687
+ // 已经在 state.seq 里的 data 重复投递会造成终端重复输出,必须过滤。
688
+ const alreadyInSnapshot = event.event === "data"
689
+ ? typeof event.seq === "number" && event.seq <= state.seq
690
+ : false;
691
+ if (!alreadyInSnapshot)
692
+ this.routeEvent(event);
693
+ }
694
+ }
695
+ if (state.status !== "running")
696
+ process = null;
697
+ return {
698
+ process,
699
+ state,
700
+ replay: state.chunks.filter((chunk) => chunk.seq > afterSeq),
701
+ isNew,
702
+ };
703
+ }
704
+ rejectPending(error) {
705
+ for (const pending of this.pending.values()) {
706
+ clearTimeout(pending.timer);
707
+ pending.reject(error);
708
+ }
709
+ this.pending.clear();
710
+ }
711
+ }
712
+ /** `TerminalSpawnRequest.env` 允许 undefined 值,协议里只接受字符串。 */
713
+ function toRenderEnv(env) {
714
+ const out = {};
715
+ for (const [key, value] of Object.entries(env)) {
716
+ if (typeof value === "string")
717
+ out[key] = value;
718
+ }
719
+ return out;
720
+ }
721
+ function warnStateIssue(message) {
722
+ process.stderr.write(`[wand] Render session state rejected: ${message}\n`);
723
+ }
724
+ /**
725
+ * 运行期校验 Render 的 SessionState。
726
+ *
727
+ * 协议是跨语言契约(Rust 侧字段名漂移过一次就是静默的空终端),所以这里不做
728
+ * 结构断言直接使用,而是逐字段检查:能修的补默认值,修不了的整条丢弃并打警告。
729
+ */
730
+ export function normalizeRenderSessionState(value) {
731
+ if (!value || typeof value !== "object") {
732
+ warnStateIssue(`expected an object, got ${value === null ? "null" : typeof value}`);
733
+ return null;
734
+ }
735
+ const record = value;
736
+ const sessionId = asString(record.sessionId);
737
+ const incarnationId = asString(record.incarnationId);
738
+ if (!sessionId || !incarnationId) {
739
+ warnStateIssue(`missing sessionId/incarnationId (${JSON.stringify(record.sessionId)}/${JSON.stringify(record.incarnationId)})`);
740
+ return null;
741
+ }
742
+ const problems = [];
743
+ const pid = asNumber(record.pid, 0, problems, "pid");
744
+ const cols = asNumber(record.cols, 80, problems, "cols");
745
+ const rows = asNumber(record.rows, 24, problems, "rows");
746
+ const seq = asNumber(record.seq, 0, problems, "seq");
747
+ const status = record.status === "exited" ? "exited" : record.status === "running" ? "running" : null;
748
+ if (status === null)
749
+ problems.push(`status=${JSON.stringify(record.status)}`);
750
+ const exitCode = typeof record.exitCode === "number" ? record.exitCode : null;
751
+ const output = typeof record.output === "string" ? record.output : "";
752
+ if (typeof record.output !== "string")
753
+ problems.push(`output=${typeof record.output}`);
754
+ const chunks = normalizeChunks(record.chunks, problems);
755
+ const terminalSnapshot = normalizeSnapshot(record.terminalSnapshot, problems);
756
+ const launchMarkerToken = typeof record.launchMarkerToken === "string" ? record.launchMarkerToken : null;
757
+ if (problems.length > 0) {
758
+ // 补齐后继续用,比整条会话变成空终端好;但一定要留痕。
759
+ process.stderr.write(`[wand] Render session state for ${sessionId} has unexpected fields: ${problems.join(", ")}\n`);
760
+ }
761
+ return {
762
+ sessionId,
763
+ incarnationId,
764
+ pid,
765
+ status: status ?? "exited",
766
+ exitCode,
767
+ cols,
768
+ rows,
769
+ seq,
770
+ output,
771
+ chunks,
772
+ terminalSnapshot,
773
+ launchMarkerToken,
774
+ };
775
+ }
776
+ function normalizeChunks(value, problems) {
777
+ if (value === undefined || value === null)
778
+ return [];
779
+ if (!Array.isArray(value)) {
780
+ problems.push(`chunks=${typeof value}`);
781
+ return [];
782
+ }
783
+ const out = [];
784
+ for (const entry of value) {
785
+ if (!entry || typeof entry !== "object")
786
+ continue;
787
+ const record = entry;
788
+ if (typeof record.data !== "string" || typeof record.seq !== "number")
789
+ continue;
790
+ out.push({ data: record.data, seq: record.seq });
791
+ }
792
+ if (out.length !== value.length)
793
+ problems.push(`chunks dropped ${value.length - out.length}/${value.length} malformed entries`);
794
+ return out;
795
+ }
796
+ function normalizeSnapshot(value, problems) {
797
+ if (value === undefined || value === null)
798
+ return null;
799
+ if (typeof value !== "object") {
800
+ problems.push(`terminalSnapshot=${typeof value}`);
801
+ return null;
802
+ }
803
+ const record = value;
804
+ if (record.version !== 1) {
805
+ // 版本不同的快照语义未知,写进客户端可能得到错误屏幕;宁可退回重放 chunks。
806
+ problems.push(`terminalSnapshot.version=${JSON.stringify(record.version)}`);
807
+ return null;
808
+ }
809
+ if (typeof record.data !== "string" || typeof record.cols !== "number" || typeof record.rows !== "number") {
810
+ problems.push("terminalSnapshot shape mismatch");
811
+ return null;
812
+ }
813
+ const pending = [];
814
+ if (!Array.isArray(record.pending)) {
815
+ problems.push(`terminalSnapshot.pending=${typeof record.pending}`);
816
+ return { version: 1, data: record.data, cols: record.cols, rows: record.rows, pending };
817
+ }
818
+ for (const entry of record.pending) {
819
+ if (!entry || typeof entry !== "object") {
820
+ problems.push("terminalSnapshot.pending has a malformed entry");
821
+ continue;
822
+ }
823
+ const op = entry;
824
+ if (op.type === "data" && typeof op.data === "string")
825
+ pending.push({ type: "data", data: op.data });
826
+ else if (op.type === "resize" && typeof op.cols === "number" && typeof op.rows === "number") {
827
+ pending.push({ type: "resize", cols: op.cols, rows: op.rows });
828
+ }
829
+ else {
830
+ // 丢掉未知 pending 会让客户端屏幕与 Render 不一致,必须留痕。
831
+ problems.push(`terminalSnapshot.pending dropped ${JSON.stringify(op.type)}`);
832
+ }
833
+ }
834
+ return { version: 1, data: record.data, cols: record.cols, rows: record.rows, pending };
835
+ }
836
+ /**
837
+ * 连接前的对端校验:必须是属于当前用户、且 group/other 不可写的 unix socket。
838
+ *
839
+ * socket 路径固定派生在 /tmp,抢注竞态是真实威胁:抢注者能拿到随首个 hello
840
+ * 发出的 token。属于别的用户直接拒绝;同用户其余情况(权限过宽)也拒绝。
841
+ * 不校验 0600 是因为 `net.Server.listen` 的默认 umask 结果是 0755,而真正
842
+ * 决定安全性的「谁能写」已由 group/other 位覆盖。
843
+ */
844
+ function assertSocketOwnership(socketPath) {
845
+ let stats;
846
+ try {
847
+ stats = lstatSync(socketPath);
848
+ }
849
+ catch (error) {
850
+ throw new Error(`Render socket ${socketPath} is not accessible: ${getErrorMessage(error)}`);
851
+ }
852
+ if (!stats.isSocket()) {
853
+ throw new Error(`Render socket ${socketPath} is not a unix socket; refusing to hand over the Render token.`);
854
+ }
855
+ const uid = typeof process.getuid === "function" ? process.getuid() : null;
856
+ if (uid !== null && stats.uid !== uid) {
857
+ throw new Error(`Render socket ${socketPath} is owned by uid ${stats.uid}, not by this user (${uid}); ` +
858
+ "refusing to hand over the Render token.");
859
+ }
860
+ if ((stats.mode & 0o022) !== 0) {
861
+ throw new Error(`Render socket ${socketPath} is writable by group/other (mode ${(stats.mode & 0o777).toString(8)}); ` +
862
+ "refusing to hand over the Render token.");
863
+ }
864
+ }
865
+ function asString(value) {
866
+ return typeof value === "string" ? value : "";
867
+ }
868
+ function asNumber(value, fallback, problems, label) {
869
+ if (typeof value === "number" && Number.isFinite(value))
870
+ return value;
871
+ problems.push(`${label}=${JSON.stringify(value)}`);
872
+ return fallback;
873
+ }
874
+ /**
875
+ * 连接一个已经存在的 Render(socket + token 都在)。**不 spawn**,找不到就返回 null,
876
+ * 这样调用方可以区分「已被别处 adopt」和「需要自己拉起」。
877
+ */
878
+ export async function connectExistingRenderClient(configPath) {
879
+ const { socketPath, tokenPath } = renderPaths(configPath);
880
+ let token;
881
+ try {
882
+ token = readFileSync(tokenPath, "utf8").trim();
883
+ }
884
+ catch {
885
+ return null;
886
+ }
887
+ if (!token)
888
+ return null;
889
+ // 传 tokenPath 让重连路径在 daemon 轮换 token 后能自愈(见构造函数注释)。
890
+ const client = new RenderDaemonClient(socketPath, token, tokenPath);
891
+ try {
892
+ await client.connect();
893
+ return client;
894
+ }
895
+ catch (error) {
896
+ if (process.env.WAND_RENDER_DEBUG === "1") {
897
+ process.stderr.write(`[wand] Render adoption failed: ${getErrorMessage(error)}\n`);
898
+ }
899
+ client.disconnect();
900
+ return null;
901
+ }
902
+ }