@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 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
- // buffer 命名空间在 xterm 5.x 是提案 API,必须开 allowProposedApi
1559
- return new HeadlessTerminal({ cols, rows, scrollback: 0, allowProposedApi: true });
1769
+ screen.dispose();
1560
1770
  }
1561
1771
  catch {
1562
- return null;
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
- try {
1982
- session.screen?.write(text);
1983
- }
1984
- catch {
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
- throw new Error(`虚拟屏不可用: ${input.sid}`);
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 () => {