@hyzyn/dsh-tty 0.20.0 → 0.20.2
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/client.js +34 -3
- package/lib/index.d.ts +133 -2
- package/lib/index.js +324 -70
- package/lib/index.js.map +1 -1
- package/package.json +13 -6
- package/scripts/integration.mjs +46 -7
- package/scripts/preview.mjs +16 -1
- package/scripts/probe-route-smoke.mjs +3 -1
- package/scripts/screen-crash-repro.mjs +62 -0
- package/scripts/sftplimits-smoke.mjs +3 -1
- package/scripts/windows-smoke.mjs +3 -1
package/lib/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import WebSocket, { WebSocketServer } from 'ws';
|
|
|
12
12
|
// found),必须默认导入后取 Terminal;类型用 InstanceType 别名保持同名可用
|
|
13
13
|
import xtermHeadless from '@xterm/headless';
|
|
14
14
|
const HeadlessTerminal = xtermHeadless.Terminal;
|
|
15
|
-
import { definePlugin, dshHome as resolveDshHome } from '@hyzyn/dsh-kit';
|
|
15
|
+
import { definePlugin, dshHome as resolveDshHome, plainConfig, settingsEntryScope, suppressAutoSettingsPage } from '@hyzyn/dsh-kit';
|
|
16
16
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
17
17
|
import { spawnSsh, sshTarget, expandHome, setCredentialResolver } from './ssh.js';
|
|
18
18
|
import { probeSsh } from './probe.js';
|
|
@@ -55,30 +55,37 @@ const TUNNEL_SCHEMA = z.object({
|
|
|
55
55
|
localTargetPort: z.natural().max(65535).default(0),
|
|
56
56
|
enabled: z.boolean().default(true),
|
|
57
57
|
});
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
58
|
+
/**
|
|
59
|
+
* 运行时 Config schema——DSH ≥0.1.7 起同时就是本插件的 settings 存储。
|
|
60
|
+
*
|
|
61
|
+
* 全部字段都标 `.volatile()`:它们都是「插件配置 → 终端面板」卡片可改项,而
|
|
62
|
+
* `settings.update(entryId, patch)` 只接受 volatile 路径;loader 对 volatile-only
|
|
63
|
+
* 变更原地更新引用并发 `loader/volatile-update`,不重挂插件——插件订阅后走
|
|
64
|
+
* `applyPatch` 热应用(见 @hyzyn/dsh-kit 的 settingsEntryScope)。
|
|
65
|
+
*/
|
|
66
|
+
export const Config = z.object({
|
|
67
|
+
enabled: z.boolean().default(true).volatile(),
|
|
68
|
+
announceToAgent: z.boolean().default(true).volatile(),
|
|
69
|
+
maxSessions: z.natural().max(16).default(4).volatile(),
|
|
70
|
+
shell: z.string().default('').volatile(),
|
|
71
|
+
term: z.string().default('xterm-256color').volatile(),
|
|
72
|
+
colorTerm: z.string().default('truecolor').volatile(),
|
|
73
|
+
cwd: z.string().default('').volatile(),
|
|
74
|
+
reconnectGraceSec: z.natural().max(3600).default(120).volatile(),
|
|
75
|
+
sshHosts: z.array(SSH_HOST_SCHEMA).default([]).volatile(),
|
|
76
|
+
hostKeys: z.array(HOST_KEY_SCHEMA).default([]).volatile(),
|
|
77
|
+
tunnels: z.array(TUNNEL_SCHEMA).default([]).volatile(),
|
|
78
|
+
shellIntegration: z.boolean().default(true).volatile(),
|
|
79
|
+
sftpStyle: z.union([z.const('dialog'), z.const('dual')]).default('dialog').volatile(),
|
|
80
|
+
persistence: z.union([z.const('off'), z.const('tmux')]).default('off').volatile(),
|
|
81
|
+
endOnPageClose: z.boolean().default(false).volatile(),
|
|
82
|
+
statsEnabled: z.boolean().default(true).volatile(),
|
|
76
83
|
sftpLimits: z.object({
|
|
77
84
|
maxDownloadMb: z.natural().max(1024 * 1024).default(1024),
|
|
78
85
|
maxUploadMb: z.natural().max(1024 * 1024).default(2048),
|
|
79
86
|
maxUploadFiles: z.natural().max(100000).default(1000),
|
|
80
|
-
}).default({ maxDownloadMb: 1024, maxUploadMb: 2048, maxUploadFiles: 1000 }),
|
|
81
|
-
persistSessions: z.array(z.object({ tmuxName: z.string() })).default([]),
|
|
87
|
+
}).default({ maxDownloadMb: 1024, maxUploadMb: 2048, maxUploadFiles: 1000 }).volatile(),
|
|
88
|
+
persistSessions: z.array(z.object({ tmuxName: z.string() })).default([]).volatile(),
|
|
82
89
|
});
|
|
83
90
|
/* ------------------------------------------------------------------ *
|
|
84
91
|
* 常量
|
|
@@ -90,6 +97,13 @@ const DEFAULT_RECONNECT_GRACE_SEC = 120;
|
|
|
90
97
|
/** 下行背压阈值(ws.bufferedAmount 字节)。 */
|
|
91
98
|
const BACKPRESSURE_HIGH = 512 * 1024;
|
|
92
99
|
const BACKPRESSURE_LOW = 128 * 1024;
|
|
100
|
+
/**
|
|
101
|
+
* 显式 kill 后的 exit 帧兜底(毫秒)。PTY 句柄的 `done` 承诺「恰好 resolve 一次」,
|
|
102
|
+
* 但个别平台/后端上 forceKill 之后它迟迟不兑现(实测 rc.1 的 subprocess-local 在
|
|
103
|
+
* Linux 上会卡住),而「发过 kill 就必须收到一条 exit 帧」是前端契约(B1/B3 用例
|
|
104
|
+
* 钉的就是它)。超时即按 SIGKILL 结案;done 真回来时靠 session.exitSent 幂等忽略。
|
|
105
|
+
*/
|
|
106
|
+
const KILL_EXIT_FALLBACK_MS = 2000;
|
|
93
107
|
const SID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
94
108
|
/** 自定义命令标签(0.14.0)的长度上限:单条命令,防误传超长脚本。 */
|
|
95
109
|
const COMMAND_MAX = 2000;
|
|
@@ -179,7 +193,7 @@ function sanitizeTermValue(value, fallback) {
|
|
|
179
193
|
const trimmed = value.trim();
|
|
180
194
|
return TERM_RE.test(trimmed) ? trimmed : fallback;
|
|
181
195
|
}
|
|
182
|
-
/** 可热更新的运行时配置(
|
|
196
|
+
/** 可热更新的运行时配置(loader 的 volatile 更新事件动态应用)。 */
|
|
183
197
|
class LiveConfig {
|
|
184
198
|
shell;
|
|
185
199
|
term;
|
|
@@ -803,6 +817,196 @@ function isLoopbackUpgrade(req) {
|
|
|
803
817
|
return false;
|
|
804
818
|
}
|
|
805
819
|
}
|
|
820
|
+
/* ------------------------------------------------------------------ *
|
|
821
|
+
* 虚拟屏(xterm-headless)——构造与异常兜底(D57)
|
|
822
|
+
* ------------------------------------------------------------------ */
|
|
823
|
+
/**
|
|
824
|
+
* 虚拟屏的 scrollback 余量(D57)。
|
|
825
|
+
*
|
|
826
|
+
* **不能是 0。** xterm 的 `Buffer` 在 `scrollback: 0` 时把 `lines.maxLength` 压成
|
|
827
|
+
* `rows`,但 normal buffer 的 `_hasScrollback` 仍是 true(reflow 照常开启):输出与
|
|
828
|
+
* resize(列宽变化触发 reflow)交错时,`lines` 会短于 `ybase + y`,于是
|
|
829
|
+
* `lineFeed()` 里 `lines.get(ybase + y).isWrapped = false` 命中 `undefined` →
|
|
830
|
+
* 未捕获 `TypeError: Cannot set properties of undefined (setting 'isWrapped')`。
|
|
831
|
+
*
|
|
832
|
+
* 该异常抛在 `WriteBuffer._innerWrite` 的 `setTimeout` 回调里,写入路径的同步
|
|
833
|
+
* try/catch 结构性拦不住,会直接打死整个宿主进程(线上 `last-failure-web.log`
|
|
834
|
+
* 的堆栈即此)。
|
|
835
|
+
*
|
|
836
|
+
* 关键在 `lines.maxLength`(= rows + scrollback):`BufferService.scroll` 只在「没满」时
|
|
837
|
+
* 才 `lines.push` + `ybase++`(成对)。`scrollback: 0` 把 maxLength 钉死成 rows,
|
|
838
|
+
* 一旦有别的路径把 `ybase` 顶上去(resize 收缩 / reflow),`lines` 长度就再也追不上,
|
|
839
|
+
* `ybase + y + 1` 越界只是时间问题。留 1 行余量(maxLength = rows + 1)即维持住成对增长:
|
|
840
|
+
* 同一最小序列 `scrollback: 0` 崩 5/5,`scrollback: 1` 崩 0/5;700 块随机屏压测里
|
|
841
|
+
* `ybase` 涨到 16 也没再出现越界(见 test/screen-crash.test.ts)。
|
|
842
|
+
*
|
|
843
|
+
* 余量不影响 `tty_screen` 读数——它走 `buffer.getLine(row)`(内部 `ybase + row`,
|
|
844
|
+
* 即视口),多出来的行只在回滚区,不进读数。
|
|
845
|
+
*/
|
|
846
|
+
export const SCREEN_SCROLLBACK = 1;
|
|
847
|
+
/**
|
|
848
|
+
* 建一块虚拟屏(`tty_screen` 的数据源);失败降级为 null。
|
|
849
|
+
*
|
|
850
|
+
* 导出仅供单测(test/screen-crash.test.ts)钉住构造参数——生产路径是
|
|
851
|
+
* `SessionManager.createScreen`,它必须与这里同源(就一行委托)。
|
|
852
|
+
*/
|
|
853
|
+
export function createHeadlessScreen(cols, rows) {
|
|
854
|
+
try {
|
|
855
|
+
// buffer 命名空间在 xterm 5.x 是提案 API,必须开 allowProposedApi
|
|
856
|
+
return new HeadlessTerminal({ cols, rows, scrollback: SCREEN_SCROLLBACK, allowProposedApi: true });
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
/** xterm-headless 的异常都带这个文件名(压缩产物的堆栈里也是它)。 */
|
|
863
|
+
const XTERM_SCREEN_CRASH_RE = /xterm-headless|@xterm\/headless/;
|
|
864
|
+
/** 累计吞掉的虚拟屏异常数(只增不减;排障可见 + 单测断言用)。 */
|
|
865
|
+
let screenCrashTotal = 0;
|
|
866
|
+
/** 读累计吞掉的虚拟屏异常数。 */
|
|
867
|
+
export function xtermScreenCrashCount() {
|
|
868
|
+
return screenCrashTotal;
|
|
869
|
+
}
|
|
870
|
+
/** 判定未捕获异常是否来自虚拟屏(xterm-headless)。导出仅供单测。 */
|
|
871
|
+
export function isXtermScreenCrash(err) {
|
|
872
|
+
const stack = err instanceof Error ? (err.stack ?? '') : String(err);
|
|
873
|
+
return XTERM_SCREEN_CRASH_RE.test(stack);
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* 记账并吞掉一个虚拟屏异常;返回 true 表示已吞(非虚拟屏异常返回 false,交回调用方)。
|
|
877
|
+
* 导出仅供单测。
|
|
878
|
+
*/
|
|
879
|
+
export function swallowXtermScreenCrash(err) {
|
|
880
|
+
if (!isXtermScreenCrash(err))
|
|
881
|
+
return false;
|
|
882
|
+
screenCrashTotal++;
|
|
883
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
884
|
+
console.warn(`[dsh-tty] 虚拟屏(xterm-headless)未捕获异常已吞掉,不影响宿主(累计 ${screenCrashTotal} 次):${message}`);
|
|
885
|
+
return true;
|
|
886
|
+
}
|
|
887
|
+
let xtermGuardRefs = 0;
|
|
888
|
+
let xtermGuardHandler;
|
|
889
|
+
let xtermGuardRejectionHandler;
|
|
890
|
+
/** 解绑兜底(引用计数归零才真正摘监听器)。 */
|
|
891
|
+
function releaseXtermScreenCrashGuard() {
|
|
892
|
+
if (xtermGuardRefs === 0)
|
|
893
|
+
return;
|
|
894
|
+
if (--xtermGuardRefs > 0)
|
|
895
|
+
return;
|
|
896
|
+
if (xtermGuardHandler !== undefined) {
|
|
897
|
+
process.off('uncaughtException', xtermGuardHandler);
|
|
898
|
+
xtermGuardHandler = undefined;
|
|
899
|
+
}
|
|
900
|
+
if (xtermGuardRejectionHandler !== undefined) {
|
|
901
|
+
process.off('unhandledRejection', xtermGuardRejectionHandler);
|
|
902
|
+
xtermGuardRejectionHandler = undefined;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* 注册进程级虚拟屏异常兜底(D57):把来自 xterm-headless 的未捕获异常 / 未处理 rejection
|
|
907
|
+
* 吞掉并记账,让插件自己的 bug 不再拖垮整个 harness。幂等 + 引用计数,返回解绑函数。
|
|
908
|
+
*
|
|
909
|
+
* 覆盖两个入口:
|
|
910
|
+
* - `uncaughtException`:同步路径(`_innerWrite` 的定时器回调里抛出,见上);
|
|
911
|
+
* - `unhandledRejection`:xterm 的异步 handler(DCS/OSC)rejection 走这里,宿主实测
|
|
912
|
+
* **0 处**监听,Node 15+ 下未处理 rejection 直接杀进程。
|
|
913
|
+
*
|
|
914
|
+
* 三条边界(刻意如此,不是随手 `process.on`):
|
|
915
|
+
* 1. **只吞虚拟屏异常**——`isXtermScreenCrash` 按堆栈判定;其余异常照旧。
|
|
916
|
+
* 2. **其余异常只在「我们是唯一的监听者」时抛回**:没有本兜底时未捕获异常会让宿主退出,
|
|
917
|
+
* 抛回保住这个语义;已经有别的监听者(宿主/其它插件)时保持沉默,由它们决定——
|
|
918
|
+
* 此时抛回反而会抢在别人前面把进程杀掉。
|
|
919
|
+
* 3. `unhandledRejection` 的「抛回」还有一层必要性:**只要挂了监听器,Node 就不再走
|
|
920
|
+
* 默认的致命处理**,所以非虚拟屏的 rejection 必须由我们抛出来还原默认行为
|
|
921
|
+
* (已实测:抛回后进程照旧 exit 1)。
|
|
922
|
+
*/
|
|
923
|
+
export function installXtermScreenCrashGuard() {
|
|
924
|
+
if (xtermGuardRefs++ > 0)
|
|
925
|
+
return releaseXtermScreenCrashGuard;
|
|
926
|
+
const onUncaught = (err) => {
|
|
927
|
+
if (swallowXtermScreenCrash(err))
|
|
928
|
+
return;
|
|
929
|
+
if (process.listenerCount('uncaughtException') <= 1)
|
|
930
|
+
throw err;
|
|
931
|
+
};
|
|
932
|
+
const onRejection = (reason) => {
|
|
933
|
+
if (swallowXtermScreenCrash(reason))
|
|
934
|
+
return;
|
|
935
|
+
if (process.listenerCount('unhandledRejection') <= 1)
|
|
936
|
+
throw reason;
|
|
937
|
+
};
|
|
938
|
+
xtermGuardHandler = onUncaught;
|
|
939
|
+
xtermGuardRejectionHandler = onRejection;
|
|
940
|
+
process.on('uncaughtException', onUncaught);
|
|
941
|
+
process.on('unhandledRejection', onRejection);
|
|
942
|
+
return releaseXtermScreenCrashGuard;
|
|
943
|
+
}
|
|
944
|
+
/* ------------------------------------------------------------------ *
|
|
945
|
+
* 虚拟屏心跳(D57 停摆检测)
|
|
946
|
+
* ------------------------------------------------------------------ */
|
|
947
|
+
/**
|
|
948
|
+
* 停摆判定窗口:写出去的数据超过这么久还没解析完,就认定那块屏的解析器已停摆。
|
|
949
|
+
* 正常屏的解析是毫秒级(回调随 `_innerWrite` 逐批回来),5s 不会误伤。
|
|
950
|
+
*/
|
|
951
|
+
export const SCREEN_STALL_MS = 5000;
|
|
952
|
+
/** 退役原因①:写队列超限 / 尺寸非法导致的同步抛出。 */
|
|
953
|
+
export const SCREEN_DOWN_WRITE_REJECTED = '写入被拒(写队列超限或尺寸非法)';
|
|
954
|
+
/** 退役原因②:解析器停摆(超时窗口内没有任何一批数据被解析完)。 */
|
|
955
|
+
export const SCREEN_DOWN_STALLED = '解析停摆(xterm 在超时窗口内未回调)';
|
|
956
|
+
/** 建一份空心跳。 */
|
|
957
|
+
export function newScreenHeartbeat() {
|
|
958
|
+
return { inflight: 0, watchdog: null, lastParseAt: 0 };
|
|
959
|
+
}
|
|
960
|
+
/** 摘掉看门狗(会话结束 / 屏退役时调用,避免定时器在会话死后误报)。 */
|
|
961
|
+
export function clearScreenWatchdog(heartbeat) {
|
|
962
|
+
if (heartbeat.watchdog !== null) {
|
|
963
|
+
clearTimeout(heartbeat.watchdog);
|
|
964
|
+
heartbeat.watchdog = null;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* 写一帧到虚拟屏,并维护停摆看门狗(D57)。
|
|
969
|
+
*
|
|
970
|
+
* **为什么需要心跳**:xterm 的解析在 `WriteBuffer._innerWrite` 的 setTimeout 回调里跑,
|
|
971
|
+
* 异常被进程级兜底吞掉之后,那块屏的解析器**永久停摆**——出错的那批数据留在写队列里、
|
|
972
|
+
* `_bufferOffset` 不前进,而 `write()` 只在队列**空**时才重新调度解析。后果:`tty_screen`
|
|
973
|
+
* 一直返回**冻结的旧画面**(agent 会据此行事),写队列还会一路堆到 5e7 字符上限。
|
|
974
|
+
* 心跳把这种屏识别出来退役,`tty_screen` 改为如实报「虚拟屏不可用」。
|
|
975
|
+
*
|
|
976
|
+
* 信号用 `write(data, cb)` 的回调(xterm 解析完这批数据才回调):停摆时回调永远不来 →
|
|
977
|
+
* `inflight` 不归零 → 看门狗判定。**不能用 `onWriteParsed` 事件**——它在 5.5.0 不是
|
|
978
|
+
* 公开 API(`Terminal` 只暴露 onBell/onBinary/onCursorMove/onData/onLineFeed/onResize/
|
|
979
|
+
* onScroll/onTitleChange)。
|
|
980
|
+
*/
|
|
981
|
+
export function writeToScreen(screen, heartbeat, text, onStall, stallMs = SCREEN_STALL_MS) {
|
|
982
|
+
heartbeat.inflight++;
|
|
983
|
+
try {
|
|
984
|
+
screen.write(text, () => {
|
|
985
|
+
heartbeat.inflight = Math.max(0, heartbeat.inflight - 1);
|
|
986
|
+
heartbeat.lastParseAt = Date.now();
|
|
987
|
+
if (heartbeat.inflight === 0)
|
|
988
|
+
clearScreenWatchdog(heartbeat);
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
catch {
|
|
992
|
+
// 同步抛出(写队列超限 5e7 / 尺寸非法):屏已不可用,立刻判定停摆
|
|
993
|
+
heartbeat.inflight = Math.max(0, heartbeat.inflight - 1);
|
|
994
|
+
onStall(SCREEN_DOWN_WRITE_REJECTED);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
if (heartbeat.watchdog === null) {
|
|
998
|
+
const armedAt = Date.now();
|
|
999
|
+
heartbeat.watchdog = setTimeout(() => {
|
|
1000
|
+
heartbeat.watchdog = null;
|
|
1001
|
+
// 停摆要**双条件**:还有批次没解析完,且整个窗口内**毫无**解析进展。
|
|
1002
|
+
// 只看 inflight 会误杀连续输出的健康屏——看门狗按首次写入武装、5s 后到期时,
|
|
1003
|
+
// 活跃会话几乎总有在途批次(实测:6s 连续输出误判 1 次,见 DEFECTS D57)。
|
|
1004
|
+
if (heartbeat.inflight > 0 && heartbeat.lastParseAt < armedAt)
|
|
1005
|
+
onStall(SCREEN_DOWN_STALLED);
|
|
1006
|
+
}, stallMs);
|
|
1007
|
+
heartbeat.watchdog.unref?.();
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
806
1010
|
/* ------------------------------------------------------------------ *
|
|
807
1011
|
* 会话管理
|
|
808
1012
|
* ------------------------------------------------------------------ */
|
|
@@ -881,6 +1085,7 @@ export class SessionManager {
|
|
|
881
1085
|
retire(session) {
|
|
882
1086
|
session.closed = true;
|
|
883
1087
|
this.sessions.delete(session.id);
|
|
1088
|
+
clearScreenWatchdog(session.screenHeartbeat);
|
|
884
1089
|
try {
|
|
885
1090
|
session.screen?.dispose();
|
|
886
1091
|
}
|
|
@@ -928,6 +1133,7 @@ export class SessionManager {
|
|
|
928
1133
|
this.sessions.clear();
|
|
929
1134
|
await Promise.all(all.map((session) => {
|
|
930
1135
|
session.closed = true;
|
|
1136
|
+
clearScreenWatchdog(session.screenHeartbeat);
|
|
931
1137
|
try {
|
|
932
1138
|
session.screen?.dispose();
|
|
933
1139
|
}
|
|
@@ -955,6 +1161,8 @@ export class TtyServer {
|
|
|
955
1161
|
pendingTmux = new Map();
|
|
956
1162
|
/** 已接线的面板连接(sessions 帧广播用;比 wss.clients 更贴合「面板」语义,单测也可驱动)。 */
|
|
957
1163
|
panels = new Set();
|
|
1164
|
+
/** 会话 → 它所属连接的 sid 映射(kill 兜底结案时要从本地表里摘除)。 */
|
|
1165
|
+
sessionLocals = new WeakMap();
|
|
958
1166
|
/** WS 闸门(插件禁用时关闭):拒绝新升级 + 断开存量连接。 */
|
|
959
1167
|
wsGateOpen = true;
|
|
960
1168
|
/** 服务器状态条总开关(配置热生效;关闭时停掉全部采集,重开按订阅恢复)。 */
|
|
@@ -1479,6 +1687,8 @@ export class TtyServer {
|
|
|
1479
1687
|
buffer: '',
|
|
1480
1688
|
decoder: new StringDecoder('utf8'),
|
|
1481
1689
|
screen: this.createScreen(clampInt(cols, 80, 2, 500), clampInt(rows, 24, 2, 200)),
|
|
1690
|
+
screenHeartbeat: newScreenHeartbeat(),
|
|
1691
|
+
screenDownReason: null,
|
|
1482
1692
|
orphanedAt: null,
|
|
1483
1693
|
shellState: createShellState(),
|
|
1484
1694
|
pendingOutput: '',
|
|
@@ -1551,16 +1761,38 @@ export class TtyServer {
|
|
|
1551
1761
|
/* 已退出 */
|
|
1552
1762
|
}
|
|
1553
1763
|
void forceKill(session.handle);
|
|
1764
|
+
// 兜底:handle.done 不兑现时也要按「用户已 kill」结案(见 KILL_EXIT_FALLBACK_MS)。
|
|
1765
|
+
const timer = setTimeout(() => {
|
|
1766
|
+
this.finishSession(session, { exitCode: null, signal: 'SIGKILL' });
|
|
1767
|
+
}, KILL_EXIT_FALLBACK_MS);
|
|
1768
|
+
timer.unref?.();
|
|
1554
1769
|
}
|
|
1555
|
-
/** 每会话一块虚拟屏(xterm-headless):tty_screen 的数据源;失败降级为 null。
|
|
1770
|
+
/** 每会话一块虚拟屏(xterm-headless):tty_screen 的数据源;失败降级为 null。
|
|
1771
|
+
* 构造参数在 createHeadlessScreen(D57:scrollback 不能是 0),这里只做委托。 */
|
|
1556
1772
|
createScreen(cols, rows) {
|
|
1773
|
+
return createHeadlessScreen(cols, rows);
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* 退役一块**不可用**的虚拟屏(D57):解析停摆或写队列满时调用。
|
|
1777
|
+
*
|
|
1778
|
+
* 只摘虚拟屏,**不动会话**——PTY 还活着、浏览器面板照常收发(虚拟屏只是 `tty_screen`
|
|
1779
|
+
* 的数据源)。退役后 `tty_screen` 会如实报「虚拟屏不可用(原因)」,而不是返回冻结的
|
|
1780
|
+
* 旧画面让 agent 据此行事。
|
|
1781
|
+
*/
|
|
1782
|
+
dropScreen(session, reason) {
|
|
1783
|
+
if (session.screen === null)
|
|
1784
|
+
return;
|
|
1785
|
+
const screen = session.screen;
|
|
1786
|
+
session.screen = null;
|
|
1787
|
+
session.screenDownReason = reason;
|
|
1788
|
+
clearScreenWatchdog(session.screenHeartbeat);
|
|
1557
1789
|
try {
|
|
1558
|
-
|
|
1559
|
-
return new HeadlessTerminal({ cols, rows, scrollback: 0, allowProposedApi: true });
|
|
1790
|
+
screen.dispose();
|
|
1560
1791
|
}
|
|
1561
1792
|
catch {
|
|
1562
|
-
|
|
1793
|
+
/* 已释放 */
|
|
1563
1794
|
}
|
|
1795
|
+
console.warn(`[dsh-tty] 虚拟屏已退役(${reason}),会话 ${session.id} 的 tty_screen 将报不可用;PTY 与前端不受影响`);
|
|
1564
1796
|
}
|
|
1565
1797
|
async handleMessage(ws, msg, local, cleanupAll, conn) {
|
|
1566
1798
|
try {
|
|
@@ -1723,6 +1955,8 @@ export class TtyServer {
|
|
|
1723
1955
|
buffer: '',
|
|
1724
1956
|
decoder: new StringDecoder('utf8'),
|
|
1725
1957
|
screen: this.createScreen(clampInt(msg.cols, 80, 2, 500), clampInt(msg.rows, 24, 2, 200)),
|
|
1958
|
+
screenHeartbeat: newScreenHeartbeat(),
|
|
1959
|
+
screenDownReason: null,
|
|
1726
1960
|
orphanedAt: null,
|
|
1727
1961
|
shellState: createShellState(),
|
|
1728
1962
|
pendingOutput: '',
|
|
@@ -1915,33 +2149,42 @@ export class TtyServer {
|
|
|
1915
2149
|
}
|
|
1916
2150
|
/** 会话退出事实 → exit 帧(恰好一次;本地 PTY 与 SSH 共用)。 */
|
|
1917
2151
|
watchDone(session, local) {
|
|
2152
|
+
this.sessionLocals.set(session, local);
|
|
1918
2153
|
session.handle.done.then((outcome) => {
|
|
1919
|
-
|
|
1920
|
-
// 发送走 session.ws 动态取值——attach 换连接后 exit 也能跟着新连接走
|
|
1921
|
-
if (session.exitSent === true)
|
|
1922
|
-
return;
|
|
1923
|
-
session.exitSent = true;
|
|
1924
|
-
session.closed = true;
|
|
1925
|
-
session.statsSubs.clear();
|
|
1926
|
-
this.stopStats(session);
|
|
1927
|
-
local.delete(session.id);
|
|
1928
|
-
this.sessions.remove(session.id);
|
|
1929
|
-
if (session.kind === 'ssh' && session.tmuxName !== null)
|
|
1930
|
-
this.trackPersist(session.tmuxName, false);
|
|
1931
|
-
try {
|
|
1932
|
-
session.screen?.dispose();
|
|
1933
|
-
}
|
|
1934
|
-
catch {
|
|
1935
|
-
/* 已释放 */
|
|
1936
|
-
}
|
|
1937
|
-
this.flushPendingOutput(session); // exit 前冲掉合并窗口里的尾巴,保序
|
|
1938
|
-
// exit 广播到所有绑定连接(跨窗口共享),各客户端按自己的 sid 收址
|
|
1939
|
-
for (const client of session.clients.values()) {
|
|
1940
|
-
send(client.ws, { t: 'exit', sid: client.sid, code: outcome.exitCode, signal: outcome.signal });
|
|
1941
|
-
}
|
|
1942
|
-
session.clients.clear();
|
|
2154
|
+
this.finishSession(session, outcome);
|
|
1943
2155
|
}).catch(() => { });
|
|
1944
2156
|
}
|
|
2157
|
+
/**
|
|
2158
|
+
* 会话终局的**唯一出口**:退役 + 清理 + 给所有绑定连接发 exit 帧(恰好一次)。
|
|
2159
|
+
*
|
|
2160
|
+
* `outcome` 正常来自 PTY 句柄的 done;显式 kill 的兜底(KILL_EXIT_FALLBACK_MS)
|
|
2161
|
+
* 也走这里,带 code=null / signal=SIGKILL。exit 广播到所有绑定连接(跨窗口共享),
|
|
2162
|
+
* 各客户端按自己的 sid 收址。
|
|
2163
|
+
*/
|
|
2164
|
+
finishSession(session, outcome) {
|
|
2165
|
+
if (session.exitSent === true)
|
|
2166
|
+
return;
|
|
2167
|
+
session.exitSent = true;
|
|
2168
|
+
session.closed = true;
|
|
2169
|
+
session.statsSubs.clear();
|
|
2170
|
+
this.stopStats(session);
|
|
2171
|
+
this.sessionLocals.get(session)?.delete(session.id);
|
|
2172
|
+
this.sessions.remove(session.id);
|
|
2173
|
+
if (session.kind === 'ssh' && session.tmuxName !== null)
|
|
2174
|
+
this.trackPersist(session.tmuxName, false);
|
|
2175
|
+
clearScreenWatchdog(session.screenHeartbeat);
|
|
2176
|
+
try {
|
|
2177
|
+
session.screen?.dispose();
|
|
2178
|
+
}
|
|
2179
|
+
catch {
|
|
2180
|
+
/* 已释放 */
|
|
2181
|
+
}
|
|
2182
|
+
this.flushPendingOutput(session); // exit 前冲掉合并窗口里的尾巴,保序
|
|
2183
|
+
for (const client of session.clients.values()) {
|
|
2184
|
+
send(client.ws, { t: 'exit', sid: client.sid, code: outcome.exitCode, signal: outcome.signal });
|
|
2185
|
+
}
|
|
2186
|
+
session.clients.clear();
|
|
2187
|
+
}
|
|
1945
2188
|
/** 输出下行 + 基于 ws.bufferedAmount 的背压(暂停/恢复 PassThrough)。 */
|
|
1946
2189
|
attachOutput(session) {
|
|
1947
2190
|
const output = session.handle.output;
|
|
@@ -1978,11 +2221,12 @@ export class TtyServer {
|
|
|
1978
2221
|
session.lastOutputAt = Date.now();
|
|
1979
2222
|
session.buffer = tailFromSafeBoundary(session.buffer + text, BUFFER_CAP);
|
|
1980
2223
|
feedShellIntegration(session, text);
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
2224
|
+
const screen = session.screen;
|
|
2225
|
+
if (screen !== null) {
|
|
2226
|
+
// 心跳包裹(D57):同步抛出 / 解析停摆的屏会被退役,而不是让 tty_screen 一直返回冻结画面
|
|
2227
|
+
writeToScreen(screen, session.screenHeartbeat, text, (reason) => {
|
|
2228
|
+
this.dropScreen(session, reason);
|
|
2229
|
+
});
|
|
1986
2230
|
}
|
|
1987
2231
|
if (session.clients.size === 0)
|
|
1988
2232
|
return; // 孤儿会话:仅积累缓冲,等待重连 attach 回放
|
|
@@ -2341,7 +2585,9 @@ const plugin = definePlugin({
|
|
|
2341
2585
|
// 声明 inject:tools 服务只有声明式 inject 才能解析(动态 ctx.inject/ctx.get
|
|
2342
2586
|
// 均拿不到,实测 mcp-client 同款模式),声明后 ctx.get('tools') 才能取到。
|
|
2343
2587
|
inject: ['tools'],
|
|
2344
|
-
apply(ctx,
|
|
2588
|
+
apply(ctx, rawConfig) {
|
|
2589
|
+
// volatile 字段解析后是 `{ get() }` 引用,先还原成纯数据(见 @hyzyn/dsh-kit 的 plainConfig)。
|
|
2590
|
+
const config = plainConfig((rawConfig ?? {}));
|
|
2345
2591
|
if (config?.enabled === false)
|
|
2346
2592
|
return;
|
|
2347
2593
|
const live = new LiveConfig({
|
|
@@ -2433,7 +2679,7 @@ const plugin = definePlugin({
|
|
|
2433
2679
|
*/
|
|
2434
2680
|
platform: process.platform,
|
|
2435
2681
|
});
|
|
2436
|
-
/** 规范化并应用一份配置补丁(
|
|
2682
|
+
/** 规范化并应用一份配置补丁(volatile 更新事件与 HTTP POST 共用;幂等)。 */
|
|
2437
2683
|
const applyPatch = (section) => {
|
|
2438
2684
|
live.apply({
|
|
2439
2685
|
shell: typeof section.shell === 'string' ? section.shell : undefined,
|
|
@@ -2653,8 +2899,8 @@ const plugin = definePlugin({
|
|
|
2653
2899
|
const scope = settingsScope;
|
|
2654
2900
|
if (scope !== undefined) {
|
|
2655
2901
|
try {
|
|
2656
|
-
//
|
|
2657
|
-
//
|
|
2902
|
+
// 官方持久化通道:settings.update(entryId) 写进本插件 entry 的 profile
|
|
2903
|
+
// patch(volatile 字段),成功后 loader 发 volatile 更新 → applyPatch 热应用
|
|
2658
2904
|
await scope.update(patch);
|
|
2659
2905
|
}
|
|
2660
2906
|
catch (error) {
|
|
@@ -3119,11 +3365,16 @@ const plugin = definePlugin({
|
|
|
3119
3365
|
};
|
|
3120
3366
|
}, 'dsh-tty: web routes');
|
|
3121
3367
|
});
|
|
3122
|
-
// settings
|
|
3368
|
+
// settings:DSH ≥0.1.7 起存储就是本插件 entry 的 Config(导出为 `Config`,可写字段
|
|
3369
|
+
// 标了 volatile)。启动合并一次持久化值,之后由 loader 的 volatile 更新事件热应用。
|
|
3123
3370
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
3124
3371
|
settingsCtx.effect(() => {
|
|
3125
|
-
const
|
|
3126
|
-
|
|
3372
|
+
const scope = settingsEntryScope(settingsCtx, 'tty');
|
|
3373
|
+
// 服务形态不符(老宿主 / 最小宿主)时不装 scope:卡片仍可看快照,只是不持久化。
|
|
3374
|
+
if (scope === undefined)
|
|
3375
|
+
return () => { };
|
|
3376
|
+
// 卡片是自定义页(plugins.row.config),别再让 DSH 为本 entry 自动生成一份。
|
|
3377
|
+
const offAutoPage = suppressAutoSettingsPage(settingsCtx, ctx);
|
|
3127
3378
|
settingsScope = scope;
|
|
3128
3379
|
// 启动合并:字符串字段非空才覆盖;maxSessions/布尔用「非默认值才覆盖」启发式
|
|
3129
3380
|
//(schema 默认值会混入 resolved,无法区分「显式保存的 4」与「从未保存」)。
|
|
@@ -3168,14 +3419,10 @@ const plugin = definePlugin({
|
|
|
3168
3419
|
startup.tunnels = storedTunnels;
|
|
3169
3420
|
if (Object.keys(startup).length > 0)
|
|
3170
3421
|
applyPatch(startup);
|
|
3171
|
-
const
|
|
3172
|
-
const off = events.events.on('settings/updated', (ns, next) => {
|
|
3173
|
-
if (ns !== 'tty' || typeof next !== 'object' || next === null)
|
|
3174
|
-
return;
|
|
3175
|
-
applyPatch(next);
|
|
3176
|
-
});
|
|
3422
|
+
const off = scope.onChanged(() => applyPatch(scope.get()));
|
|
3177
3423
|
return () => {
|
|
3178
3424
|
off();
|
|
3425
|
+
offAutoPage();
|
|
3179
3426
|
settingsScope = undefined;
|
|
3180
3427
|
};
|
|
3181
3428
|
}, 'dsh-tty: settings');
|
|
@@ -3470,8 +3717,11 @@ const plugin = definePlugin({
|
|
|
3470
3717
|
if (session === undefined || session.closed)
|
|
3471
3718
|
throw new Error(`会话不存在或已退出: ${input.sid}`);
|
|
3472
3719
|
const screen = session.screen;
|
|
3473
|
-
if (screen === null)
|
|
3474
|
-
|
|
3720
|
+
if (screen === null) {
|
|
3721
|
+
// D57:屏可能被退役(解析停摆 / 写队列满)——如实报原因,别让 agent 以为只是没开
|
|
3722
|
+
const why = session.screenDownReason === null ? '' : `(${session.screenDownReason})`;
|
|
3723
|
+
throw new Error(`虚拟屏不可用: ${input.sid}${why}`);
|
|
3724
|
+
}
|
|
3475
3725
|
const buffer = screen.buffer.active;
|
|
3476
3726
|
const lines = [];
|
|
3477
3727
|
for (let row = 0; row < screen.rows; row++) {
|
|
@@ -4110,6 +4360,10 @@ const plugin = definePlugin({
|
|
|
4110
4360
|
}, REAPER_INTERVAL_MS);
|
|
4111
4361
|
reaperTimer.unref?.();
|
|
4112
4362
|
ctx.effect(() => () => clearInterval(reaperTimer), 'dsh-tty: orphan reaper');
|
|
4363
|
+
// 虚拟屏异常兜底(D57):xterm-headless 的解析跑在 WriteBuffer 的 setTimeout
|
|
4364
|
+
// 回调里,写入路径的同步 try/catch 结构性拦不住;没有兜底时任何一处虚拟屏异常
|
|
4365
|
+
// 都会直接打死宿主进程(Web GUI 掉线、会话表清空、agent 全丢)。插件卸载时摘掉。
|
|
4366
|
+
ctx.effect(() => installXtermScreenCrashGuard(), 'dsh-tty: xterm crash guard');
|
|
4113
4367
|
// 插件卸载时回收全部会话、隧道与 SFTP 连接
|
|
4114
4368
|
ctx.effect(() => {
|
|
4115
4369
|
return () => {
|