@hyzyn/dsh-tty 0.20.0 → 0.20.1
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.
- package/lib/index.d.ts +112 -1
- package/lib/index.js +233 -11
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/scripts/screen-crash-repro.mjs +62 -0
package/lib/index.d.ts
CHANGED
|
@@ -188,6 +188,10 @@ interface TtySession {
|
|
|
188
188
|
decoder: StringDecoder;
|
|
189
189
|
/** 虚拟屏(xterm-headless):tty_screen 的数据源;创建失败为 null。 */
|
|
190
190
|
screen: HeadlessTerminal | null;
|
|
191
|
+
/** 虚拟屏心跳(D57 停摆检测):在途批次 + 看门狗。 */
|
|
192
|
+
screenHeartbeat: ScreenHeartbeat;
|
|
193
|
+
/** 虚拟屏被退役的原因(停摆 / 写队列满);null = 正常。tty_screen 据此如实报错。 */
|
|
194
|
+
screenDownReason: string | null;
|
|
191
195
|
/** 转入孤儿状态的时间戳;null 表示已连接(客户端在线)。 */
|
|
192
196
|
orphanedAt: number | null;
|
|
193
197
|
/** shell 集成状态(OSC 133/7 解析;本地与 SSH 会话都喂)。 */
|
|
@@ -319,6 +323,104 @@ declare class HostKeyStore {
|
|
|
319
323
|
/** 记录指纹:同 host:port 已有记录则并入集合(一机多把钥匙),否则新建。 */
|
|
320
324
|
record(host: string, port: number, fingerprint: string): void;
|
|
321
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* 虚拟屏的 scrollback 余量(D57)。
|
|
328
|
+
*
|
|
329
|
+
* **不能是 0。** xterm 的 `Buffer` 在 `scrollback: 0` 时把 `lines.maxLength` 压成
|
|
330
|
+
* `rows`,但 normal buffer 的 `_hasScrollback` 仍是 true(reflow 照常开启):输出与
|
|
331
|
+
* resize(列宽变化触发 reflow)交错时,`lines` 会短于 `ybase + y`,于是
|
|
332
|
+
* `lineFeed()` 里 `lines.get(ybase + y).isWrapped = false` 命中 `undefined` →
|
|
333
|
+
* 未捕获 `TypeError: Cannot set properties of undefined (setting 'isWrapped')`。
|
|
334
|
+
*
|
|
335
|
+
* 该异常抛在 `WriteBuffer._innerWrite` 的 `setTimeout` 回调里,写入路径的同步
|
|
336
|
+
* try/catch 结构性拦不住,会直接打死整个宿主进程(线上 `last-failure-web.log`
|
|
337
|
+
* 的堆栈即此)。
|
|
338
|
+
*
|
|
339
|
+
* 关键在 `lines.maxLength`(= rows + scrollback):`BufferService.scroll` 只在「没满」时
|
|
340
|
+
* 才 `lines.push` + `ybase++`(成对)。`scrollback: 0` 把 maxLength 钉死成 rows,
|
|
341
|
+
* 一旦有别的路径把 `ybase` 顶上去(resize 收缩 / reflow),`lines` 长度就再也追不上,
|
|
342
|
+
* `ybase + y + 1` 越界只是时间问题。留 1 行余量(maxLength = rows + 1)即维持住成对增长:
|
|
343
|
+
* 同一最小序列 `scrollback: 0` 崩 5/5,`scrollback: 1` 崩 0/5;700 块随机屏压测里
|
|
344
|
+
* `ybase` 涨到 16 也没再出现越界(见 test/screen-crash.test.ts)。
|
|
345
|
+
*
|
|
346
|
+
* 余量不影响 `tty_screen` 读数——它走 `buffer.getLine(row)`(内部 `ybase + row`,
|
|
347
|
+
* 即视口),多出来的行只在回滚区,不进读数。
|
|
348
|
+
*/
|
|
349
|
+
export declare const SCREEN_SCROLLBACK = 1;
|
|
350
|
+
/**
|
|
351
|
+
* 建一块虚拟屏(`tty_screen` 的数据源);失败降级为 null。
|
|
352
|
+
*
|
|
353
|
+
* 导出仅供单测(test/screen-crash.test.ts)钉住构造参数——生产路径是
|
|
354
|
+
* `SessionManager.createScreen`,它必须与这里同源(就一行委托)。
|
|
355
|
+
*/
|
|
356
|
+
export declare function createHeadlessScreen(cols: number, rows: number): HeadlessTerminal | null;
|
|
357
|
+
/** 读累计吞掉的虚拟屏异常数。 */
|
|
358
|
+
export declare function xtermScreenCrashCount(): number;
|
|
359
|
+
/** 判定未捕获异常是否来自虚拟屏(xterm-headless)。导出仅供单测。 */
|
|
360
|
+
export declare function isXtermScreenCrash(err: unknown): boolean;
|
|
361
|
+
/**
|
|
362
|
+
* 记账并吞掉一个虚拟屏异常;返回 true 表示已吞(非虚拟屏异常返回 false,交回调用方)。
|
|
363
|
+
* 导出仅供单测。
|
|
364
|
+
*/
|
|
365
|
+
export declare function swallowXtermScreenCrash(err: unknown): boolean;
|
|
366
|
+
/**
|
|
367
|
+
* 注册进程级虚拟屏异常兜底(D57):把来自 xterm-headless 的未捕获异常 / 未处理 rejection
|
|
368
|
+
* 吞掉并记账,让插件自己的 bug 不再拖垮整个 harness。幂等 + 引用计数,返回解绑函数。
|
|
369
|
+
*
|
|
370
|
+
* 覆盖两个入口:
|
|
371
|
+
* - `uncaughtException`:同步路径(`_innerWrite` 的定时器回调里抛出,见上);
|
|
372
|
+
* - `unhandledRejection`:xterm 的异步 handler(DCS/OSC)rejection 走这里,宿主实测
|
|
373
|
+
* **0 处**监听,Node 15+ 下未处理 rejection 直接杀进程。
|
|
374
|
+
*
|
|
375
|
+
* 三条边界(刻意如此,不是随手 `process.on`):
|
|
376
|
+
* 1. **只吞虚拟屏异常**——`isXtermScreenCrash` 按堆栈判定;其余异常照旧。
|
|
377
|
+
* 2. **其余异常只在「我们是唯一的监听者」时抛回**:没有本兜底时未捕获异常会让宿主退出,
|
|
378
|
+
* 抛回保住这个语义;已经有别的监听者(宿主/其它插件)时保持沉默,由它们决定——
|
|
379
|
+
* 此时抛回反而会抢在别人前面把进程杀掉。
|
|
380
|
+
* 3. `unhandledRejection` 的「抛回」还有一层必要性:**只要挂了监听器,Node 就不再走
|
|
381
|
+
* 默认的致命处理**,所以非虚拟屏的 rejection 必须由我们抛出来还原默认行为
|
|
382
|
+
* (已实测:抛回后进程照旧 exit 1)。
|
|
383
|
+
*/
|
|
384
|
+
export declare function installXtermScreenCrashGuard(): () => void;
|
|
385
|
+
/**
|
|
386
|
+
* 停摆判定窗口:写出去的数据超过这么久还没解析完,就认定那块屏的解析器已停摆。
|
|
387
|
+
* 正常屏的解析是毫秒级(回调随 `_innerWrite` 逐批回来),5s 不会误伤。
|
|
388
|
+
*/
|
|
389
|
+
export declare const SCREEN_STALL_MS = 5000;
|
|
390
|
+
/** 退役原因①:写队列超限 / 尺寸非法导致的同步抛出。 */
|
|
391
|
+
export declare const SCREEN_DOWN_WRITE_REJECTED = "\u5199\u5165\u88AB\u62D2\uFF08\u5199\u961F\u5217\u8D85\u9650\u6216\u5C3A\u5BF8\u975E\u6CD5\uFF09";
|
|
392
|
+
/** 退役原因②:解析器停摆(超时窗口内没有任何一批数据被解析完)。 */
|
|
393
|
+
export declare const SCREEN_DOWN_STALLED = "\u89E3\u6790\u505C\u6446\uFF08xterm \u5728\u8D85\u65F6\u7A97\u53E3\u5185\u672A\u56DE\u8C03\uFF09";
|
|
394
|
+
/** 虚拟屏心跳:在途批次 + 看门狗(D57)。 */
|
|
395
|
+
export interface ScreenHeartbeat {
|
|
396
|
+
/** 已写出、尚未被 xterm 解析完的批次(`write(data, cb)` 的回调未回来即 >0)。 */
|
|
397
|
+
inflight: number;
|
|
398
|
+
/** 看门狗;null = 当前没有挂着的窗口。 */
|
|
399
|
+
watchdog: NodeJS.Timeout | null;
|
|
400
|
+
/** 最近一次解析完成的时间戳(0 = 从未)。看门狗靠它区分「解析在途」与「真停摆」。 */
|
|
401
|
+
lastParseAt: number;
|
|
402
|
+
}
|
|
403
|
+
/** 建一份空心跳。 */
|
|
404
|
+
export declare function newScreenHeartbeat(): ScreenHeartbeat;
|
|
405
|
+
/** 摘掉看门狗(会话结束 / 屏退役时调用,避免定时器在会话死后误报)。 */
|
|
406
|
+
export declare function clearScreenWatchdog(heartbeat: ScreenHeartbeat): void;
|
|
407
|
+
/**
|
|
408
|
+
* 写一帧到虚拟屏,并维护停摆看门狗(D57)。
|
|
409
|
+
*
|
|
410
|
+
* **为什么需要心跳**:xterm 的解析在 `WriteBuffer._innerWrite` 的 setTimeout 回调里跑,
|
|
411
|
+
* 异常被进程级兜底吞掉之后,那块屏的解析器**永久停摆**——出错的那批数据留在写队列里、
|
|
412
|
+
* `_bufferOffset` 不前进,而 `write()` 只在队列**空**时才重新调度解析。后果:`tty_screen`
|
|
413
|
+
* 一直返回**冻结的旧画面**(agent 会据此行事),写队列还会一路堆到 5e7 字符上限。
|
|
414
|
+
* 心跳把这种屏识别出来退役,`tty_screen` 改为如实报「虚拟屏不可用」。
|
|
415
|
+
*
|
|
416
|
+
* 信号用 `write(data, cb)` 的回调(xterm 解析完这批数据才回调):停摆时回调永远不来 →
|
|
417
|
+
* `inflight` 不归零 → 看门狗判定。**不能用 `onWriteParsed` 事件**——它在 5.5.0 不是
|
|
418
|
+
* 公开 API(`Terminal` 只暴露 onBell/onBinary/onCursorMove/onData/onLineFeed/onResize/
|
|
419
|
+
* onScroll/onTitleChange)。
|
|
420
|
+
*/
|
|
421
|
+
export declare function writeToScreen(screen: {
|
|
422
|
+
write(data: string, callback?: () => void): void;
|
|
423
|
+
}, heartbeat: ScreenHeartbeat, text: string, onStall: (reason: string) => void, stallMs?: number): void;
|
|
322
424
|
/** 导出仅供单测(test/host-frames.test.ts):上限 / 孤儿回收 / grace 热改的行为护栏。 */
|
|
323
425
|
export declare class SessionManager {
|
|
324
426
|
private readonly sessions;
|
|
@@ -515,8 +617,17 @@ export declare class TtyServer {
|
|
|
515
617
|
* 会话会留在 tmux server 上);2.5s 兜底 forceKill 防收尾悬挂。
|
|
516
618
|
*/
|
|
517
619
|
private killSessionNow;
|
|
518
|
-
/** 每会话一块虚拟屏(xterm-headless):tty_screen 的数据源;失败降级为 null。
|
|
620
|
+
/** 每会话一块虚拟屏(xterm-headless):tty_screen 的数据源;失败降级为 null。
|
|
621
|
+
* 构造参数在 createHeadlessScreen(D57:scrollback 不能是 0),这里只做委托。 */
|
|
519
622
|
private createScreen;
|
|
623
|
+
/**
|
|
624
|
+
* 退役一块**不可用**的虚拟屏(D57):解析停摆或写队列满时调用。
|
|
625
|
+
*
|
|
626
|
+
* 只摘虚拟屏,**不动会话**——PTY 还活着、浏览器面板照常收发(虚拟屏只是 `tty_screen`
|
|
627
|
+
* 的数据源)。退役后 `tty_screen` 会如实报「虚拟屏不可用(原因)」,而不是返回冻结的
|
|
628
|
+
* 旧画面让 agent 据此行事。
|
|
629
|
+
*/
|
|
630
|
+
private dropScreen;
|
|
520
631
|
private handleMessage;
|
|
521
632
|
/**
|
|
522
633
|
* 服务器状态条订阅(0.17.0):按「标签可见性」驱动——只有可见标签才发
|
package/lib/index.js
CHANGED
|
@@ -803,6 +803,196 @@ function isLoopbackUpgrade(req) {
|
|
|
803
803
|
return false;
|
|
804
804
|
}
|
|
805
805
|
}
|
|
806
|
+
/* ------------------------------------------------------------------ *
|
|
807
|
+
* 虚拟屏(xterm-headless)——构造与异常兜底(D57)
|
|
808
|
+
* ------------------------------------------------------------------ */
|
|
809
|
+
/**
|
|
810
|
+
* 虚拟屏的 scrollback 余量(D57)。
|
|
811
|
+
*
|
|
812
|
+
* **不能是 0。** xterm 的 `Buffer` 在 `scrollback: 0` 时把 `lines.maxLength` 压成
|
|
813
|
+
* `rows`,但 normal buffer 的 `_hasScrollback` 仍是 true(reflow 照常开启):输出与
|
|
814
|
+
* resize(列宽变化触发 reflow)交错时,`lines` 会短于 `ybase + y`,于是
|
|
815
|
+
* `lineFeed()` 里 `lines.get(ybase + y).isWrapped = false` 命中 `undefined` →
|
|
816
|
+
* 未捕获 `TypeError: Cannot set properties of undefined (setting 'isWrapped')`。
|
|
817
|
+
*
|
|
818
|
+
* 该异常抛在 `WriteBuffer._innerWrite` 的 `setTimeout` 回调里,写入路径的同步
|
|
819
|
+
* try/catch 结构性拦不住,会直接打死整个宿主进程(线上 `last-failure-web.log`
|
|
820
|
+
* 的堆栈即此)。
|
|
821
|
+
*
|
|
822
|
+
* 关键在 `lines.maxLength`(= rows + scrollback):`BufferService.scroll` 只在「没满」时
|
|
823
|
+
* 才 `lines.push` + `ybase++`(成对)。`scrollback: 0` 把 maxLength 钉死成 rows,
|
|
824
|
+
* 一旦有别的路径把 `ybase` 顶上去(resize 收缩 / reflow),`lines` 长度就再也追不上,
|
|
825
|
+
* `ybase + y + 1` 越界只是时间问题。留 1 行余量(maxLength = rows + 1)即维持住成对增长:
|
|
826
|
+
* 同一最小序列 `scrollback: 0` 崩 5/5,`scrollback: 1` 崩 0/5;700 块随机屏压测里
|
|
827
|
+
* `ybase` 涨到 16 也没再出现越界(见 test/screen-crash.test.ts)。
|
|
828
|
+
*
|
|
829
|
+
* 余量不影响 `tty_screen` 读数——它走 `buffer.getLine(row)`(内部 `ybase + row`,
|
|
830
|
+
* 即视口),多出来的行只在回滚区,不进读数。
|
|
831
|
+
*/
|
|
832
|
+
export const SCREEN_SCROLLBACK = 1;
|
|
833
|
+
/**
|
|
834
|
+
* 建一块虚拟屏(`tty_screen` 的数据源);失败降级为 null。
|
|
835
|
+
*
|
|
836
|
+
* 导出仅供单测(test/screen-crash.test.ts)钉住构造参数——生产路径是
|
|
837
|
+
* `SessionManager.createScreen`,它必须与这里同源(就一行委托)。
|
|
838
|
+
*/
|
|
839
|
+
export function createHeadlessScreen(cols, rows) {
|
|
840
|
+
try {
|
|
841
|
+
// buffer 命名空间在 xterm 5.x 是提案 API,必须开 allowProposedApi
|
|
842
|
+
return new HeadlessTerminal({ cols, rows, scrollback: SCREEN_SCROLLBACK, allowProposedApi: true });
|
|
843
|
+
}
|
|
844
|
+
catch {
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
/** xterm-headless 的异常都带这个文件名(压缩产物的堆栈里也是它)。 */
|
|
849
|
+
const XTERM_SCREEN_CRASH_RE = /xterm-headless|@xterm\/headless/;
|
|
850
|
+
/** 累计吞掉的虚拟屏异常数(只增不减;排障可见 + 单测断言用)。 */
|
|
851
|
+
let screenCrashTotal = 0;
|
|
852
|
+
/** 读累计吞掉的虚拟屏异常数。 */
|
|
853
|
+
export function xtermScreenCrashCount() {
|
|
854
|
+
return screenCrashTotal;
|
|
855
|
+
}
|
|
856
|
+
/** 判定未捕获异常是否来自虚拟屏(xterm-headless)。导出仅供单测。 */
|
|
857
|
+
export function isXtermScreenCrash(err) {
|
|
858
|
+
const stack = err instanceof Error ? (err.stack ?? '') : String(err);
|
|
859
|
+
return XTERM_SCREEN_CRASH_RE.test(stack);
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* 记账并吞掉一个虚拟屏异常;返回 true 表示已吞(非虚拟屏异常返回 false,交回调用方)。
|
|
863
|
+
* 导出仅供单测。
|
|
864
|
+
*/
|
|
865
|
+
export function swallowXtermScreenCrash(err) {
|
|
866
|
+
if (!isXtermScreenCrash(err))
|
|
867
|
+
return false;
|
|
868
|
+
screenCrashTotal++;
|
|
869
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
870
|
+
console.warn(`[dsh-tty] 虚拟屏(xterm-headless)未捕获异常已吞掉,不影响宿主(累计 ${screenCrashTotal} 次):${message}`);
|
|
871
|
+
return true;
|
|
872
|
+
}
|
|
873
|
+
let xtermGuardRefs = 0;
|
|
874
|
+
let xtermGuardHandler;
|
|
875
|
+
let xtermGuardRejectionHandler;
|
|
876
|
+
/** 解绑兜底(引用计数归零才真正摘监听器)。 */
|
|
877
|
+
function releaseXtermScreenCrashGuard() {
|
|
878
|
+
if (xtermGuardRefs === 0)
|
|
879
|
+
return;
|
|
880
|
+
if (--xtermGuardRefs > 0)
|
|
881
|
+
return;
|
|
882
|
+
if (xtermGuardHandler !== undefined) {
|
|
883
|
+
process.off('uncaughtException', xtermGuardHandler);
|
|
884
|
+
xtermGuardHandler = undefined;
|
|
885
|
+
}
|
|
886
|
+
if (xtermGuardRejectionHandler !== undefined) {
|
|
887
|
+
process.off('unhandledRejection', xtermGuardRejectionHandler);
|
|
888
|
+
xtermGuardRejectionHandler = undefined;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* 注册进程级虚拟屏异常兜底(D57):把来自 xterm-headless 的未捕获异常 / 未处理 rejection
|
|
893
|
+
* 吞掉并记账,让插件自己的 bug 不再拖垮整个 harness。幂等 + 引用计数,返回解绑函数。
|
|
894
|
+
*
|
|
895
|
+
* 覆盖两个入口:
|
|
896
|
+
* - `uncaughtException`:同步路径(`_innerWrite` 的定时器回调里抛出,见上);
|
|
897
|
+
* - `unhandledRejection`:xterm 的异步 handler(DCS/OSC)rejection 走这里,宿主实测
|
|
898
|
+
* **0 处**监听,Node 15+ 下未处理 rejection 直接杀进程。
|
|
899
|
+
*
|
|
900
|
+
* 三条边界(刻意如此,不是随手 `process.on`):
|
|
901
|
+
* 1. **只吞虚拟屏异常**——`isXtermScreenCrash` 按堆栈判定;其余异常照旧。
|
|
902
|
+
* 2. **其余异常只在「我们是唯一的监听者」时抛回**:没有本兜底时未捕获异常会让宿主退出,
|
|
903
|
+
* 抛回保住这个语义;已经有别的监听者(宿主/其它插件)时保持沉默,由它们决定——
|
|
904
|
+
* 此时抛回反而会抢在别人前面把进程杀掉。
|
|
905
|
+
* 3. `unhandledRejection` 的「抛回」还有一层必要性:**只要挂了监听器,Node 就不再走
|
|
906
|
+
* 默认的致命处理**,所以非虚拟屏的 rejection 必须由我们抛出来还原默认行为
|
|
907
|
+
* (已实测:抛回后进程照旧 exit 1)。
|
|
908
|
+
*/
|
|
909
|
+
export function installXtermScreenCrashGuard() {
|
|
910
|
+
if (xtermGuardRefs++ > 0)
|
|
911
|
+
return releaseXtermScreenCrashGuard;
|
|
912
|
+
const onUncaught = (err) => {
|
|
913
|
+
if (swallowXtermScreenCrash(err))
|
|
914
|
+
return;
|
|
915
|
+
if (process.listenerCount('uncaughtException') <= 1)
|
|
916
|
+
throw err;
|
|
917
|
+
};
|
|
918
|
+
const onRejection = (reason) => {
|
|
919
|
+
if (swallowXtermScreenCrash(reason))
|
|
920
|
+
return;
|
|
921
|
+
if (process.listenerCount('unhandledRejection') <= 1)
|
|
922
|
+
throw reason;
|
|
923
|
+
};
|
|
924
|
+
xtermGuardHandler = onUncaught;
|
|
925
|
+
xtermGuardRejectionHandler = onRejection;
|
|
926
|
+
process.on('uncaughtException', onUncaught);
|
|
927
|
+
process.on('unhandledRejection', onRejection);
|
|
928
|
+
return releaseXtermScreenCrashGuard;
|
|
929
|
+
}
|
|
930
|
+
/* ------------------------------------------------------------------ *
|
|
931
|
+
* 虚拟屏心跳(D57 停摆检测)
|
|
932
|
+
* ------------------------------------------------------------------ */
|
|
933
|
+
/**
|
|
934
|
+
* 停摆判定窗口:写出去的数据超过这么久还没解析完,就认定那块屏的解析器已停摆。
|
|
935
|
+
* 正常屏的解析是毫秒级(回调随 `_innerWrite` 逐批回来),5s 不会误伤。
|
|
936
|
+
*/
|
|
937
|
+
export const SCREEN_STALL_MS = 5000;
|
|
938
|
+
/** 退役原因①:写队列超限 / 尺寸非法导致的同步抛出。 */
|
|
939
|
+
export const SCREEN_DOWN_WRITE_REJECTED = '写入被拒(写队列超限或尺寸非法)';
|
|
940
|
+
/** 退役原因②:解析器停摆(超时窗口内没有任何一批数据被解析完)。 */
|
|
941
|
+
export const SCREEN_DOWN_STALLED = '解析停摆(xterm 在超时窗口内未回调)';
|
|
942
|
+
/** 建一份空心跳。 */
|
|
943
|
+
export function newScreenHeartbeat() {
|
|
944
|
+
return { inflight: 0, watchdog: null, lastParseAt: 0 };
|
|
945
|
+
}
|
|
946
|
+
/** 摘掉看门狗(会话结束 / 屏退役时调用,避免定时器在会话死后误报)。 */
|
|
947
|
+
export function clearScreenWatchdog(heartbeat) {
|
|
948
|
+
if (heartbeat.watchdog !== null) {
|
|
949
|
+
clearTimeout(heartbeat.watchdog);
|
|
950
|
+
heartbeat.watchdog = null;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* 写一帧到虚拟屏,并维护停摆看门狗(D57)。
|
|
955
|
+
*
|
|
956
|
+
* **为什么需要心跳**:xterm 的解析在 `WriteBuffer._innerWrite` 的 setTimeout 回调里跑,
|
|
957
|
+
* 异常被进程级兜底吞掉之后,那块屏的解析器**永久停摆**——出错的那批数据留在写队列里、
|
|
958
|
+
* `_bufferOffset` 不前进,而 `write()` 只在队列**空**时才重新调度解析。后果:`tty_screen`
|
|
959
|
+
* 一直返回**冻结的旧画面**(agent 会据此行事),写队列还会一路堆到 5e7 字符上限。
|
|
960
|
+
* 心跳把这种屏识别出来退役,`tty_screen` 改为如实报「虚拟屏不可用」。
|
|
961
|
+
*
|
|
962
|
+
* 信号用 `write(data, cb)` 的回调(xterm 解析完这批数据才回调):停摆时回调永远不来 →
|
|
963
|
+
* `inflight` 不归零 → 看门狗判定。**不能用 `onWriteParsed` 事件**——它在 5.5.0 不是
|
|
964
|
+
* 公开 API(`Terminal` 只暴露 onBell/onBinary/onCursorMove/onData/onLineFeed/onResize/
|
|
965
|
+
* onScroll/onTitleChange)。
|
|
966
|
+
*/
|
|
967
|
+
export function writeToScreen(screen, heartbeat, text, onStall, stallMs = SCREEN_STALL_MS) {
|
|
968
|
+
heartbeat.inflight++;
|
|
969
|
+
try {
|
|
970
|
+
screen.write(text, () => {
|
|
971
|
+
heartbeat.inflight = Math.max(0, heartbeat.inflight - 1);
|
|
972
|
+
heartbeat.lastParseAt = Date.now();
|
|
973
|
+
if (heartbeat.inflight === 0)
|
|
974
|
+
clearScreenWatchdog(heartbeat);
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
catch {
|
|
978
|
+
// 同步抛出(写队列超限 5e7 / 尺寸非法):屏已不可用,立刻判定停摆
|
|
979
|
+
heartbeat.inflight = Math.max(0, heartbeat.inflight - 1);
|
|
980
|
+
onStall(SCREEN_DOWN_WRITE_REJECTED);
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
if (heartbeat.watchdog === null) {
|
|
984
|
+
const armedAt = Date.now();
|
|
985
|
+
heartbeat.watchdog = setTimeout(() => {
|
|
986
|
+
heartbeat.watchdog = null;
|
|
987
|
+
// 停摆要**双条件**:还有批次没解析完,且整个窗口内**毫无**解析进展。
|
|
988
|
+
// 只看 inflight 会误杀连续输出的健康屏——看门狗按首次写入武装、5s 后到期时,
|
|
989
|
+
// 活跃会话几乎总有在途批次(实测:6s 连续输出误判 1 次,见 DEFECTS D57)。
|
|
990
|
+
if (heartbeat.inflight > 0 && heartbeat.lastParseAt < armedAt)
|
|
991
|
+
onStall(SCREEN_DOWN_STALLED);
|
|
992
|
+
}, stallMs);
|
|
993
|
+
heartbeat.watchdog.unref?.();
|
|
994
|
+
}
|
|
995
|
+
}
|
|
806
996
|
/* ------------------------------------------------------------------ *
|
|
807
997
|
* 会话管理
|
|
808
998
|
* ------------------------------------------------------------------ */
|
|
@@ -881,6 +1071,7 @@ export class SessionManager {
|
|
|
881
1071
|
retire(session) {
|
|
882
1072
|
session.closed = true;
|
|
883
1073
|
this.sessions.delete(session.id);
|
|
1074
|
+
clearScreenWatchdog(session.screenHeartbeat);
|
|
884
1075
|
try {
|
|
885
1076
|
session.screen?.dispose();
|
|
886
1077
|
}
|
|
@@ -928,6 +1119,7 @@ export class SessionManager {
|
|
|
928
1119
|
this.sessions.clear();
|
|
929
1120
|
await Promise.all(all.map((session) => {
|
|
930
1121
|
session.closed = true;
|
|
1122
|
+
clearScreenWatchdog(session.screenHeartbeat);
|
|
931
1123
|
try {
|
|
932
1124
|
session.screen?.dispose();
|
|
933
1125
|
}
|
|
@@ -1479,6 +1671,8 @@ export class TtyServer {
|
|
|
1479
1671
|
buffer: '',
|
|
1480
1672
|
decoder: new StringDecoder('utf8'),
|
|
1481
1673
|
screen: this.createScreen(clampInt(cols, 80, 2, 500), clampInt(rows, 24, 2, 200)),
|
|
1674
|
+
screenHeartbeat: newScreenHeartbeat(),
|
|
1675
|
+
screenDownReason: null,
|
|
1482
1676
|
orphanedAt: null,
|
|
1483
1677
|
shellState: createShellState(),
|
|
1484
1678
|
pendingOutput: '',
|
|
@@ -1552,15 +1746,32 @@ export class TtyServer {
|
|
|
1552
1746
|
}
|
|
1553
1747
|
void forceKill(session.handle);
|
|
1554
1748
|
}
|
|
1555
|
-
/** 每会话一块虚拟屏(xterm-headless):tty_screen 的数据源;失败降级为 null。
|
|
1749
|
+
/** 每会话一块虚拟屏(xterm-headless):tty_screen 的数据源;失败降级为 null。
|
|
1750
|
+
* 构造参数在 createHeadlessScreen(D57:scrollback 不能是 0),这里只做委托。 */
|
|
1556
1751
|
createScreen(cols, rows) {
|
|
1752
|
+
return createHeadlessScreen(cols, rows);
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* 退役一块**不可用**的虚拟屏(D57):解析停摆或写队列满时调用。
|
|
1756
|
+
*
|
|
1757
|
+
* 只摘虚拟屏,**不动会话**——PTY 还活着、浏览器面板照常收发(虚拟屏只是 `tty_screen`
|
|
1758
|
+
* 的数据源)。退役后 `tty_screen` 会如实报「虚拟屏不可用(原因)」,而不是返回冻结的
|
|
1759
|
+
* 旧画面让 agent 据此行事。
|
|
1760
|
+
*/
|
|
1761
|
+
dropScreen(session, reason) {
|
|
1762
|
+
if (session.screen === null)
|
|
1763
|
+
return;
|
|
1764
|
+
const screen = session.screen;
|
|
1765
|
+
session.screen = null;
|
|
1766
|
+
session.screenDownReason = reason;
|
|
1767
|
+
clearScreenWatchdog(session.screenHeartbeat);
|
|
1557
1768
|
try {
|
|
1558
|
-
|
|
1559
|
-
return new HeadlessTerminal({ cols, rows, scrollback: 0, allowProposedApi: true });
|
|
1769
|
+
screen.dispose();
|
|
1560
1770
|
}
|
|
1561
1771
|
catch {
|
|
1562
|
-
|
|
1772
|
+
/* 已释放 */
|
|
1563
1773
|
}
|
|
1774
|
+
console.warn(`[dsh-tty] 虚拟屏已退役(${reason}),会话 ${session.id} 的 tty_screen 将报不可用;PTY 与前端不受影响`);
|
|
1564
1775
|
}
|
|
1565
1776
|
async handleMessage(ws, msg, local, cleanupAll, conn) {
|
|
1566
1777
|
try {
|
|
@@ -1723,6 +1934,8 @@ export class TtyServer {
|
|
|
1723
1934
|
buffer: '',
|
|
1724
1935
|
decoder: new StringDecoder('utf8'),
|
|
1725
1936
|
screen: this.createScreen(clampInt(msg.cols, 80, 2, 500), clampInt(msg.rows, 24, 2, 200)),
|
|
1937
|
+
screenHeartbeat: newScreenHeartbeat(),
|
|
1938
|
+
screenDownReason: null,
|
|
1726
1939
|
orphanedAt: null,
|
|
1727
1940
|
shellState: createShellState(),
|
|
1728
1941
|
pendingOutput: '',
|
|
@@ -1928,6 +2141,7 @@ export class TtyServer {
|
|
|
1928
2141
|
this.sessions.remove(session.id);
|
|
1929
2142
|
if (session.kind === 'ssh' && session.tmuxName !== null)
|
|
1930
2143
|
this.trackPersist(session.tmuxName, false);
|
|
2144
|
+
clearScreenWatchdog(session.screenHeartbeat);
|
|
1931
2145
|
try {
|
|
1932
2146
|
session.screen?.dispose();
|
|
1933
2147
|
}
|
|
@@ -1978,11 +2192,12 @@ export class TtyServer {
|
|
|
1978
2192
|
session.lastOutputAt = Date.now();
|
|
1979
2193
|
session.buffer = tailFromSafeBoundary(session.buffer + text, BUFFER_CAP);
|
|
1980
2194
|
feedShellIntegration(session, text);
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
2195
|
+
const screen = session.screen;
|
|
2196
|
+
if (screen !== null) {
|
|
2197
|
+
// 心跳包裹(D57):同步抛出 / 解析停摆的屏会被退役,而不是让 tty_screen 一直返回冻结画面
|
|
2198
|
+
writeToScreen(screen, session.screenHeartbeat, text, (reason) => {
|
|
2199
|
+
this.dropScreen(session, reason);
|
|
2200
|
+
});
|
|
1986
2201
|
}
|
|
1987
2202
|
if (session.clients.size === 0)
|
|
1988
2203
|
return; // 孤儿会话:仅积累缓冲,等待重连 attach 回放
|
|
@@ -3470,8 +3685,11 @@ const plugin = definePlugin({
|
|
|
3470
3685
|
if (session === undefined || session.closed)
|
|
3471
3686
|
throw new Error(`会话不存在或已退出: ${input.sid}`);
|
|
3472
3687
|
const screen = session.screen;
|
|
3473
|
-
if (screen === null)
|
|
3474
|
-
|
|
3688
|
+
if (screen === null) {
|
|
3689
|
+
// D57:屏可能被退役(解析停摆 / 写队列满)——如实报原因,别让 agent 以为只是没开
|
|
3690
|
+
const why = session.screenDownReason === null ? '' : `(${session.screenDownReason})`;
|
|
3691
|
+
throw new Error(`虚拟屏不可用: ${input.sid}${why}`);
|
|
3692
|
+
}
|
|
3475
3693
|
const buffer = screen.buffer.active;
|
|
3476
3694
|
const lines = [];
|
|
3477
3695
|
for (let row = 0; row < screen.rows; row++) {
|
|
@@ -4110,6 +4328,10 @@ const plugin = definePlugin({
|
|
|
4110
4328
|
}, REAPER_INTERVAL_MS);
|
|
4111
4329
|
reaperTimer.unref?.();
|
|
4112
4330
|
ctx.effect(() => () => clearInterval(reaperTimer), 'dsh-tty: orphan reaper');
|
|
4331
|
+
// 虚拟屏异常兜底(D57):xterm-headless 的解析跑在 WriteBuffer 的 setTimeout
|
|
4332
|
+
// 回调里,写入路径的同步 try/catch 结构性拦不住;没有兜底时任何一处虚拟屏异常
|
|
4333
|
+
// 都会直接打死宿主进程(Web GUI 掉线、会话表清空、agent 全丢)。插件卸载时摘掉。
|
|
4334
|
+
ctx.effect(() => installXtermScreenCrashGuard(), 'dsh-tty: xterm crash guard');
|
|
4113
4335
|
// 插件卸载时回收全部会话、隧道与 SFTP 连接
|
|
4114
4336
|
ctx.effect(() => {
|
|
4115
4337
|
return () => {
|