@hyzyn/dsh-docker 0.9.1 → 0.9.3
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/README.en.md +78 -7
- package/README.md +91 -13
- package/client-src/docker.css +22 -3
- package/client-src/index.js +448 -56
- package/client-src/log-stream.js +15 -0
- package/client-src/log-window.js +244 -0
- package/client.js +45 -26
- package/lib/index.d.ts +33 -0
- package/lib/index.js +218 -34
- package/lib/index.js.map +1 -1
- package/lib/ssh-exec.d.ts +57 -7
- package/lib/ssh-exec.js +227 -78
- package/lib/ssh-exec.js.map +1 -1
- package/package.json +3 -3
- package/scripts/client-smoke.mjs +126 -10
- package/scripts/log-perf.mjs +327 -10
package/lib/ssh-exec.d.ts
CHANGED
|
@@ -245,6 +245,22 @@ export declare function streamBudgetError(target: string, busy: number, max?: nu
|
|
|
245
245
|
*/
|
|
246
246
|
export declare const SSH_TIMEOUT_HINT: string;
|
|
247
247
|
export declare function describeExecError(message: string): string;
|
|
248
|
+
/**
|
|
249
|
+
* 这条错误是不是**通道额度被远端占满**(D150)。
|
|
250
|
+
*
|
|
251
|
+
* sshd 侧的原话是 `error: no more sessions`(`session_new()` 在
|
|
252
|
+
* `sessions_nalloc >= options.max_sessions` 时返回 NULL),ssh2 把它翻译成
|
|
253
|
+
* `(SSH) Channel open failure: open failed` —— 两个形态都要认。
|
|
254
|
+
*
|
|
255
|
+
* 与 {@link isTransportError} 分开判定的理由:它**曾经**被当成「连接健康、别重连」的一类
|
|
256
|
+
* (D07),但线上实证(2026-09-29,248)表明被占满的连接会**一直是满的**:
|
|
257
|
+
* ① 客户端中止长流时发的 `signal('KILL')` 在部分 sshd 上被直接拒绝
|
|
258
|
+
* (`error: session_signal_req: session signalling requires privilege separation`);
|
|
259
|
+
* ② sshd 在子进程仍活着时**延迟释放** session 槽(session.c:`delay detach of session`)。
|
|
260
|
+
* 两条叠加后,插件侧 `busy` 归零而远端槽位仍未归还,于是「重试」永远打在一条满连接上。
|
|
261
|
+
* 重建连接是唯一能让额度立刻归零的动作,所以这一类改判为「丢连接 + 重建一次」。
|
|
262
|
+
*/
|
|
263
|
+
export declare function isChannelExhaustedError(message: string): boolean;
|
|
248
264
|
/**
|
|
249
265
|
* 这条 ssh2 错误是不是**传输层 / 连接层**的(而不是命令自己失败)。
|
|
250
266
|
*
|
|
@@ -253,10 +269,12 @@ export declare function describeExecError(message: string): string;
|
|
|
253
269
|
* 这条连接、重连一次再试;反过来,「命令返回非零」「镜像不存在」这类业务失败**绝不能**
|
|
254
270
|
* 触发重连——那会把一次普通错误变成两条命令。
|
|
255
271
|
*
|
|
256
|
-
* 「Channel open failure / open failed
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
272
|
+
* 「Channel open failure / open failed」**不在**这份名单里(D07 的取舍仍然成立:它是远端拒绝
|
|
273
|
+
* 开新通道,连接本身未必是死的,而且把它当传输错误会让 `describeExecError` 补的可操作文案
|
|
274
|
+
* 被一次成功的重连藏掉)。但 D150(2026-09-29 线上实证)补了一条**独立分支**:识别为
|
|
275
|
+
* {@link isChannelExhaustedError} 时也丢连接重建,**并且**在日志里留一行 warn——
|
|
276
|
+
* 因为被占满的连接不会自己恢复(见 {@link isChannelExhaustedError} 的注释),
|
|
277
|
+
* 不重建就永远是那句「重试也没用」。
|
|
260
278
|
*/
|
|
261
279
|
export declare function isTransportError(message: string): boolean;
|
|
262
280
|
/**
|
|
@@ -270,6 +288,30 @@ export declare function shouldRecycleConn(conn: {
|
|
|
270
288
|
busy: number;
|
|
271
289
|
inflight?: number;
|
|
272
290
|
}, now: number, idleMs?: number): boolean;
|
|
291
|
+
/**
|
|
292
|
+
* 每条连接上的短命令闸门(FIFO,D150)。`acquire()` 返回释放函数;超出上限的调用排队。
|
|
293
|
+
*
|
|
294
|
+
* 名额是**转交**而不是「先减后加」:释放时若队列里有人,直接把名额交给它、`active` 不减,
|
|
295
|
+
* 否则同一 tick 里新来的 `acquire()` 会看到一个空位、与刚被唤醒的等待者**同时**拿到名额
|
|
296
|
+
* (并发数超限,而这正是闸门要防的事)。
|
|
297
|
+
*
|
|
298
|
+
* `dispose()` 放行全部等待者(插件卸载时不能让排队中的命令永远挂着);此时 `active` 与真实
|
|
299
|
+
* 占用的对应关系不再有意义,所以减法一律 `Math.max(0, …)`。
|
|
300
|
+
*/
|
|
301
|
+
export declare class ShortChannelGate {
|
|
302
|
+
private readonly limit;
|
|
303
|
+
private active;
|
|
304
|
+
private readonly waiters;
|
|
305
|
+
constructor(limit?: number);
|
|
306
|
+
acquire(): Promise<() => void>;
|
|
307
|
+
/** 占用中的并发数(测试缝)。 */
|
|
308
|
+
get inUse(): number;
|
|
309
|
+
/** 排队中的调用数(测试缝)。 */
|
|
310
|
+
get queued(): number;
|
|
311
|
+
/** 放行全部等待者(卸载路径;幂等)。 */
|
|
312
|
+
dispose(): void;
|
|
313
|
+
private releaseFn;
|
|
314
|
+
}
|
|
273
315
|
/** 远程一次性命令执行器:懒连接池 + TOFU 指纹 + 输出上限。 */
|
|
274
316
|
export declare class RemoteExec {
|
|
275
317
|
private readonly logger;
|
|
@@ -283,6 +325,8 @@ export declare class RemoteExec {
|
|
|
283
325
|
*/
|
|
284
326
|
private readonly options;
|
|
285
327
|
private readonly conns;
|
|
328
|
+
/** 每个池键一条短命令闸门(D150);与连接同寿命,连接被重建也不重置配额。 */
|
|
329
|
+
private readonly gates;
|
|
286
330
|
private sweeper;
|
|
287
331
|
constructor(logger: ExecLogger, store: HostKeyStore,
|
|
288
332
|
/**
|
|
@@ -297,6 +341,11 @@ export declare class RemoteExec {
|
|
|
297
341
|
});
|
|
298
342
|
/** 插件卸载:关定时器与全部连接(幂等)。 */
|
|
299
343
|
disposeAll(): void;
|
|
344
|
+
/**
|
|
345
|
+
* 取这个池键对应的短命令闸门(懒建)。键与连接池同口径({@link poolKey})——闸门防的是
|
|
346
|
+
* 「同一条连接上的通道额度」,所以必须与「同一条连接」同键。
|
|
347
|
+
*/
|
|
348
|
+
private gateFor;
|
|
300
349
|
/** 在远程执行一条命令(argv 形式,内部做 shell 转义)。 */
|
|
301
350
|
run(spec: SshSpec, argv: readonly string[], options?: ExecOptions): Promise<ExecResult>;
|
|
302
351
|
/**
|
|
@@ -310,10 +359,11 @@ export declare class RemoteExec {
|
|
|
310
359
|
stream(spec: SshSpec, argv: readonly string[], handlers: StreamHandlers, signal?: AbortSignal): Promise<StreamResult>;
|
|
311
360
|
private ensureSweeper;
|
|
312
361
|
/**
|
|
313
|
-
* 开一条 exec channel
|
|
362
|
+
* 开一条 exec channel;**传输层**错误或**通道额度被占满**时丢掉连接、重连一次
|
|
363
|
+
* (见 `isTransportError` / `isChannelExhaustedError`)。
|
|
314
364
|
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
365
|
+
* 只重试一次:重连之后还报同样的错,多半不是连接的问题(目标本身不可达 / 新连接也被
|
|
366
|
+
* 别的东西占满),再试只是把失败拖长、还会多压一条命令过去。
|
|
317
367
|
*/
|
|
318
368
|
private openChannel;
|
|
319
369
|
private acquire;
|
package/lib/ssh-exec.js
CHANGED
|
@@ -581,6 +581,23 @@ function connectTimeoutMs() {
|
|
|
581
581
|
const raw = Number(process.env.DSH_DOCKER_CONNECT_TIMEOUT_MS);
|
|
582
582
|
return Number.isFinite(raw) && raw > 0 ? raw : 20_000;
|
|
583
583
|
}
|
|
584
|
+
/**
|
|
585
|
+
* 每个 SSH 连接上**短命令**({@link RemoteExec.run})的并发通道上限(D150)。
|
|
586
|
+
*
|
|
587
|
+
* 为什么需要:`MaxSessions` 的 10 个槽是**按连接**算的,而一个目标只维持一条连接;
|
|
588
|
+
* {@link MAX_STREAMS_PER_TARGET} 留出的那两条余量只够**串行**的短命令用。真实面板一次
|
|
589
|
+
* 点击就会并发发出不止两条(详情页的「概览 inspect」+「日志快照」+ 统计快照),agent 侧
|
|
590
|
+
* 也会并发调 `docker_logs` / `docker_inspect`。超出余量的那条通道被远端**直接拒绝**
|
|
591
|
+
* (sshd 侧 `error: no more sessions` → ssh2 `Channel open failure: open failed`),
|
|
592
|
+
* 症状是「日志读不出来」,不是「慢一点」。
|
|
593
|
+
*
|
|
594
|
+
* 所以短命令在这里**排队**而不是硬闯:最多 2 条并发,其余等前一条收尾。与
|
|
595
|
+
* {@link connectTimeoutMs} 同样的口径,可用环境变量覆盖——**只为测试与排障**。
|
|
596
|
+
*/
|
|
597
|
+
function shortChannelLimit() {
|
|
598
|
+
const raw = Number(process.env.DSH_DOCKER_SHORT_CHANNELS);
|
|
599
|
+
return Number.isInteger(raw) && raw > 0 ? raw : 2;
|
|
600
|
+
}
|
|
584
601
|
/**
|
|
585
602
|
* 每个 SSH 目标上同时可持有的**长流**上限。
|
|
586
603
|
*
|
|
@@ -630,10 +647,29 @@ export const SSH_TIMEOUT_HINT = '若该主机只能经跳板机访问,请在
|
|
|
630
647
|
export function describeExecError(message) {
|
|
631
648
|
if (/Timed out|ETIMEDOUT/i.test(message))
|
|
632
649
|
return `${message}:${SSH_TIMEOUT_HINT}`;
|
|
633
|
-
if (
|
|
650
|
+
if (!isChannelExhaustedError(message))
|
|
634
651
|
return message;
|
|
635
652
|
return `${message}(远端 sshd 拒绝了新通道:同一连接上的通道额度可能已被实时流占满——`
|
|
636
|
-
+ `OpenSSH MaxSessions 默认 10
|
|
653
|
+
+ `OpenSSH MaxSessions 默认 10;插件遇到该错误会重建连接自动重试一次,仍失败请关掉`
|
|
654
|
+
+ `部分实时跟随 / 减少聚合容器数后重试)`;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* 这条错误是不是**通道额度被远端占满**(D150)。
|
|
658
|
+
*
|
|
659
|
+
* sshd 侧的原话是 `error: no more sessions`(`session_new()` 在
|
|
660
|
+
* `sessions_nalloc >= options.max_sessions` 时返回 NULL),ssh2 把它翻译成
|
|
661
|
+
* `(SSH) Channel open failure: open failed` —— 两个形态都要认。
|
|
662
|
+
*
|
|
663
|
+
* 与 {@link isTransportError} 分开判定的理由:它**曾经**被当成「连接健康、别重连」的一类
|
|
664
|
+
* (D07),但线上实证(2026-09-29,248)表明被占满的连接会**一直是满的**:
|
|
665
|
+
* ① 客户端中止长流时发的 `signal('KILL')` 在部分 sshd 上被直接拒绝
|
|
666
|
+
* (`error: session_signal_req: session signalling requires privilege separation`);
|
|
667
|
+
* ② sshd 在子进程仍活着时**延迟释放** session 槽(session.c:`delay detach of session`)。
|
|
668
|
+
* 两条叠加后,插件侧 `busy` 归零而远端槽位仍未归还,于是「重试」永远打在一条满连接上。
|
|
669
|
+
* 重建连接是唯一能让额度立刻归零的动作,所以这一类改判为「丢连接 + 重建一次」。
|
|
670
|
+
*/
|
|
671
|
+
export function isChannelExhaustedError(message) {
|
|
672
|
+
return /Channel open failure|no more sessions|open failed/i.test(message);
|
|
637
673
|
}
|
|
638
674
|
/**
|
|
639
675
|
* 这条 ssh2 错误是不是**传输层 / 连接层**的(而不是命令自己失败)。
|
|
@@ -643,10 +679,12 @@ export function describeExecError(message) {
|
|
|
643
679
|
* 这条连接、重连一次再试;反过来,「命令返回非零」「镜像不存在」这类业务失败**绝不能**
|
|
644
680
|
* 触发重连——那会把一次普通错误变成两条命令。
|
|
645
681
|
*
|
|
646
|
-
* 「Channel open failure / open failed
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
682
|
+
* 「Channel open failure / open failed」**不在**这份名单里(D07 的取舍仍然成立:它是远端拒绝
|
|
683
|
+
* 开新通道,连接本身未必是死的,而且把它当传输错误会让 `describeExecError` 补的可操作文案
|
|
684
|
+
* 被一次成功的重连藏掉)。但 D150(2026-09-29 线上实证)补了一条**独立分支**:识别为
|
|
685
|
+
* {@link isChannelExhaustedError} 时也丢连接重建,**并且**在日志里留一行 warn——
|
|
686
|
+
* 因为被占满的连接不会自己恢复(见 {@link isChannelExhaustedError} 的注释),
|
|
687
|
+
* 不重建就永远是那句「重试也没用」。
|
|
650
688
|
*/
|
|
651
689
|
export function isTransportError(message) {
|
|
652
690
|
// 前两条是我们自己的包装文案:回调迟迟不来 = 这条连接已经不响应了
|
|
@@ -665,12 +703,69 @@ export function shouldRecycleConn(conn, now, idleMs = IDLE_MS) {
|
|
|
665
703
|
return false;
|
|
666
704
|
return now - conn.lastUsed >= idleMs;
|
|
667
705
|
}
|
|
706
|
+
/**
|
|
707
|
+
* 每条连接上的短命令闸门(FIFO,D150)。`acquire()` 返回释放函数;超出上限的调用排队。
|
|
708
|
+
*
|
|
709
|
+
* 名额是**转交**而不是「先减后加」:释放时若队列里有人,直接把名额交给它、`active` 不减,
|
|
710
|
+
* 否则同一 tick 里新来的 `acquire()` 会看到一个空位、与刚被唤醒的等待者**同时**拿到名额
|
|
711
|
+
* (并发数超限,而这正是闸门要防的事)。
|
|
712
|
+
*
|
|
713
|
+
* `dispose()` 放行全部等待者(插件卸载时不能让排队中的命令永远挂着);此时 `active` 与真实
|
|
714
|
+
* 占用的对应关系不再有意义,所以减法一律 `Math.max(0, …)`。
|
|
715
|
+
*/
|
|
716
|
+
export class ShortChannelGate {
|
|
717
|
+
limit;
|
|
718
|
+
active = 0;
|
|
719
|
+
waiters = [];
|
|
720
|
+
constructor(limit = shortChannelLimit()) {
|
|
721
|
+
this.limit = limit;
|
|
722
|
+
}
|
|
723
|
+
async acquire() {
|
|
724
|
+
if (this.active < this.limit) {
|
|
725
|
+
this.active += 1;
|
|
726
|
+
return this.releaseFn();
|
|
727
|
+
}
|
|
728
|
+
await new Promise((resolve) => this.waiters.push(resolve));
|
|
729
|
+
// 名额由释放方转交,这里不再自增
|
|
730
|
+
return this.releaseFn();
|
|
731
|
+
}
|
|
732
|
+
/** 占用中的并发数(测试缝)。 */
|
|
733
|
+
get inUse() {
|
|
734
|
+
return this.active;
|
|
735
|
+
}
|
|
736
|
+
/** 排队中的调用数(测试缝)。 */
|
|
737
|
+
get queued() {
|
|
738
|
+
return this.waiters.length;
|
|
739
|
+
}
|
|
740
|
+
/** 放行全部等待者(卸载路径;幂等)。 */
|
|
741
|
+
dispose() {
|
|
742
|
+
const pending = this.waiters.splice(0);
|
|
743
|
+
for (const resolve of pending)
|
|
744
|
+
resolve();
|
|
745
|
+
}
|
|
746
|
+
releaseFn() {
|
|
747
|
+
let released = false;
|
|
748
|
+
return () => {
|
|
749
|
+
if (released)
|
|
750
|
+
return;
|
|
751
|
+
released = true;
|
|
752
|
+
const next = this.waiters.shift();
|
|
753
|
+
if (next !== undefined) {
|
|
754
|
+
next();
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
this.active = Math.max(0, this.active - 1);
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
}
|
|
668
761
|
/** 远程一次性命令执行器:懒连接池 + TOFU 指纹 + 输出上限。 */
|
|
669
762
|
export class RemoteExec {
|
|
670
763
|
logger;
|
|
671
764
|
store;
|
|
672
765
|
options;
|
|
673
766
|
conns = new Map();
|
|
767
|
+
/** 每个池键一条短命令闸门(D150);与连接同寿命,连接被重建也不重置配额。 */
|
|
768
|
+
gates = new Map();
|
|
674
769
|
sweeper = null;
|
|
675
770
|
constructor(logger, store,
|
|
676
771
|
/**
|
|
@@ -713,6 +808,22 @@ export class RemoteExec {
|
|
|
713
808
|
rt.proxy = null;
|
|
714
809
|
}
|
|
715
810
|
this.conns.clear();
|
|
811
|
+
// 排队中的短命令一并放行(D150):卸载后它们只会立刻失败,但不能挂在闸门上
|
|
812
|
+
for (const gate of this.gates.values())
|
|
813
|
+
gate.dispose();
|
|
814
|
+
this.gates.clear();
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* 取这个池键对应的短命令闸门(懒建)。键与连接池同口径({@link poolKey})——闸门防的是
|
|
818
|
+
* 「同一条连接上的通道额度」,所以必须与「同一条连接」同键。
|
|
819
|
+
*/
|
|
820
|
+
gateFor(key) {
|
|
821
|
+
const existing = this.gates.get(key);
|
|
822
|
+
if (existing !== undefined)
|
|
823
|
+
return existing;
|
|
824
|
+
const gate = new ShortChannelGate();
|
|
825
|
+
this.gates.set(key, gate);
|
|
826
|
+
return gate;
|
|
716
827
|
}
|
|
717
828
|
/** 在远程执行一条命令(argv 形式,内部做 shell 转义)。 */
|
|
718
829
|
async run(spec, argv, options) {
|
|
@@ -720,80 +831,93 @@ export class RemoteExec {
|
|
|
720
831
|
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
721
832
|
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
722
833
|
const started = Date.now();
|
|
723
|
-
const channel = await this.openChannel(spec, command, timeoutMs, 0);
|
|
724
|
-
// 一次性命令也计入「在途」(D01):docker pull 默认 600s,期间没有任何请求
|
|
725
|
-
// 刷新 lastUsed, sweeper 若只认 busy 会把跑了一半的命令连人带输出掐断。
|
|
726
834
|
const key = poolKey(spec);
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
835
|
+
/*
|
|
836
|
+
* 短命令闸门(D150):先排队再开通道,超出的调用等前一条收尾。
|
|
837
|
+
* 「开门」也算在占用里——若在拿到名额与开门之间放走另一个调用,两条通道会同时落在同一条
|
|
838
|
+
* 连接上,而额度只剩两条余量(长流上限 8 / MaxSessions 10),那正是要防的事。
|
|
839
|
+
*/
|
|
840
|
+
const releaseSlot = await this.gateFor(key).acquire();
|
|
730
841
|
try {
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
842
|
+
const channel = await this.openChannel(spec, command, timeoutMs, 0);
|
|
843
|
+
// 一次性命令也计入「在途」(D01):docker pull 默认 600s,期间没有任何请求
|
|
844
|
+
// 刷新 lastUsed, sweeper 若只认 busy 会把跑了一半的命令连人带输出掐断。
|
|
845
|
+
// 取值放在开门**之后**:openChannel 可能已经重连换过条目(D150),要算在活条目头上。
|
|
846
|
+
const rt = this.conns.get(key);
|
|
847
|
+
if (rt !== undefined)
|
|
848
|
+
rt.inflight += 1;
|
|
849
|
+
try {
|
|
850
|
+
return await new Promise((resolve, reject) => {
|
|
851
|
+
const stdoutSink = new ByteSink(maxBytes, options?.keepTail === true);
|
|
852
|
+
const stderrSink = new ByteSink(maxBytes, options?.keepTail === true);
|
|
853
|
+
const stdoutDecoder = new StringDecoder('utf8');
|
|
854
|
+
const stderrDecoder = new StringDecoder('utf8');
|
|
855
|
+
let timedOut = false;
|
|
856
|
+
let settled = false;
|
|
857
|
+
let timer = null;
|
|
858
|
+
const finish = (code) => {
|
|
859
|
+
if (settled)
|
|
860
|
+
return;
|
|
861
|
+
settled = true;
|
|
862
|
+
if (timer !== null)
|
|
863
|
+
clearTimeout(timer);
|
|
864
|
+
const current = this.conns.get(key);
|
|
865
|
+
if (current !== undefined)
|
|
866
|
+
current.lastUsed = Date.now();
|
|
867
|
+
resolve({
|
|
868
|
+
code,
|
|
869
|
+
stdout: stdoutSink.decode(stdoutDecoder),
|
|
870
|
+
stderr: stderrSink.decode(stderrDecoder),
|
|
871
|
+
timedOut,
|
|
872
|
+
truncated: stdoutSink.truncated || stderrSink.truncated,
|
|
873
|
+
durationMs: Date.now() - started,
|
|
874
|
+
});
|
|
875
|
+
};
|
|
876
|
+
// 定时器放在 finish **之后**(D112):超时除了打断远端命令,还要**直接 settle**。
|
|
877
|
+
// 原先只 signal('KILL') + close(),若通道静默不响应(既不 emit 'close' 也不 emit
|
|
878
|
+
// 'error'),promise 永不落定 → finally 里的 inflight 减不掉 → 该连接对
|
|
879
|
+
// shouldRecycleConn 永远是「在途」,sweeper 再也回收不了它。
|
|
880
|
+
// settled 守卫保证与随后的 'close' 事件不会重复 resolve(幂等)。
|
|
881
|
+
timer = setTimeout(() => {
|
|
882
|
+
timedOut = true;
|
|
883
|
+
try {
|
|
884
|
+
channel.signal('KILL');
|
|
885
|
+
}
|
|
886
|
+
catch {
|
|
887
|
+
/* 远端可能已结束 */
|
|
888
|
+
}
|
|
889
|
+
channel.close();
|
|
890
|
+
finish(null);
|
|
891
|
+
}, timeoutMs);
|
|
892
|
+
channel.on('data', (chunk) => {
|
|
893
|
+
stdoutSink.push(chunk);
|
|
755
894
|
});
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
}, timeoutMs);
|
|
773
|
-
channel.on('data', (chunk) => {
|
|
774
|
-
stdoutSink.push(chunk);
|
|
775
|
-
});
|
|
776
|
-
channel.stderr.on('data', (chunk) => {
|
|
777
|
-
stderrSink.push(chunk);
|
|
778
|
-
});
|
|
779
|
-
channel.on('close', (code) => {
|
|
780
|
-
finish(typeof code === 'number' ? code : null);
|
|
781
|
-
});
|
|
782
|
-
channel.on('error', (error) => {
|
|
783
|
-
if (settled)
|
|
784
|
-
return;
|
|
785
|
-
settled = true;
|
|
786
|
-
if (timer !== null)
|
|
787
|
-
clearTimeout(timer);
|
|
788
|
-
reject(new Error(`SSH exec channel 异常:${error.message}`));
|
|
895
|
+
channel.stderr.on('data', (chunk) => {
|
|
896
|
+
stderrSink.push(chunk);
|
|
897
|
+
});
|
|
898
|
+
channel.on('close', (code) => {
|
|
899
|
+
finish(typeof code === 'number' ? code : null);
|
|
900
|
+
});
|
|
901
|
+
channel.on('error', (error) => {
|
|
902
|
+
if (settled)
|
|
903
|
+
return;
|
|
904
|
+
settled = true;
|
|
905
|
+
if (timer !== null)
|
|
906
|
+
clearTimeout(timer);
|
|
907
|
+
reject(new Error(`SSH exec channel 异常:${error.message}`));
|
|
908
|
+
});
|
|
909
|
+
if (options?.input !== undefined)
|
|
910
|
+
channel.end(options.input);
|
|
789
911
|
});
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
912
|
+
}
|
|
913
|
+
finally {
|
|
914
|
+
if (rt !== undefined)
|
|
915
|
+
rt.inflight = Math.max(0, rt.inflight - 1);
|
|
916
|
+
}
|
|
793
917
|
}
|
|
794
918
|
finally {
|
|
795
|
-
|
|
796
|
-
|
|
919
|
+
// 释放名额(异常路径也必须放):闸门漏放 = 该目标后续所有短命令永久排队
|
|
920
|
+
releaseSlot();
|
|
797
921
|
}
|
|
798
922
|
}
|
|
799
923
|
/**
|
|
@@ -863,6 +987,15 @@ export class RemoteExec {
|
|
|
863
987
|
const stdoutDecoder = new StringDecoder('utf8');
|
|
864
988
|
const stderrDecoder = new StringDecoder('utf8');
|
|
865
989
|
let settled = false;
|
|
990
|
+
/*
|
|
991
|
+
* 中止长流:先请远端 KILL,再关通道。
|
|
992
|
+
*
|
|
993
|
+
* 注意 `signal('KILL')` **不保证生效**(D150,2026-09-29 实测 248:sshd 记
|
|
994
|
+
* `session_signal_req: session signalling requires privilege separation` 并拒绝),
|
|
995
|
+
* 而 sshd 在子进程仍活着时会延迟释放 session 槽。也就是说:这一关通道之后,远端
|
|
996
|
+
* 可能还留着一条 `docker logs -f`,并继续占着 10 个槽里的一个——插件侧的 `busy` 已经
|
|
997
|
+
* 归零,自己看不出来。真正兜底的是 openChannel 的「额度满 → 重建连接」(D150)。
|
|
998
|
+
*/
|
|
866
999
|
const onAbort = () => {
|
|
867
1000
|
if (settled)
|
|
868
1001
|
return;
|
|
@@ -958,10 +1091,11 @@ export class RemoteExec {
|
|
|
958
1091
|
this.sweeper.unref?.();
|
|
959
1092
|
}
|
|
960
1093
|
/**
|
|
961
|
-
* 开一条 exec channel
|
|
1094
|
+
* 开一条 exec channel;**传输层**错误或**通道额度被占满**时丢掉连接、重连一次
|
|
1095
|
+
* (见 `isTransportError` / `isChannelExhaustedError`)。
|
|
962
1096
|
*
|
|
963
|
-
*
|
|
964
|
-
*
|
|
1097
|
+
* 只重试一次:重连之后还报同样的错,多半不是连接的问题(目标本身不可达 / 新连接也被
|
|
1098
|
+
* 别的东西占满),再试只是把失败拖长、还会多压一条命令过去。
|
|
965
1099
|
*/
|
|
966
1100
|
async openChannel(spec, command, timeoutMs, attempt) {
|
|
967
1101
|
// acquire 放在 try **外面**(D27):建连失败(目标不可达等)不该落在「传输错误
|
|
@@ -984,7 +1118,22 @@ export class RemoteExec {
|
|
|
984
1118
|
}
|
|
985
1119
|
catch (error) {
|
|
986
1120
|
const message = error instanceof Error ? error.message : String(error);
|
|
987
|
-
|
|
1121
|
+
/*
|
|
1122
|
+
* 额度占满(D150)与传输错误走同一套「丢连接 + 关闭 + 重建一次」,但**留一行 warn**:
|
|
1123
|
+
* 与传输错误不同,连接本身是活的,用户看到的现象是「面板时好时坏、重试偶尔有用」——
|
|
1124
|
+
* 没有这行日志,事后没人能从宿主日志里看出「这条连接曾被打满、插件自己重建过一次」。
|
|
1125
|
+
* 关闭动作仍然要做:只摘出池不 end() 会让 keepalive 一直养着一条满连接(D07 的教训)。
|
|
1126
|
+
*
|
|
1127
|
+
* 代价(有意接受):`end()` 会让这条连接上正在跟随的长流一起断,由面板的 SSE 自动
|
|
1128
|
+
* 重连接管(日志流按 D133 用 tail=0 续尾、统计与事件流由 EventSource 自己重连)。
|
|
1129
|
+
* 「几秒的流抖动 + 自动恢复」比「一条永远满的连接 + 重试永远失败」划算——这也是
|
|
1130
|
+
* {@link MAX_STREAMS_PER_TARGET} 说明里那条实测症状的唯一出口。
|
|
1131
|
+
*/
|
|
1132
|
+
const exhausted = isChannelExhaustedError(message);
|
|
1133
|
+
if (attempt === 0 && (isTransportError(message) || exhausted)) {
|
|
1134
|
+
if (exhausted) {
|
|
1135
|
+
this.logger.warn(`[dsh-docker] ssh ${sshTarget(spec)} 远端拒绝了新通道(通道额度已满):已重建连接重试`);
|
|
1136
|
+
}
|
|
988
1137
|
// 丢掉这条连接并**关闭它**(D07):只摘出池不 end() 的话,keepalive 会一直
|
|
989
1138
|
// 养着一条死连接;dropConn 带身份校验,不会误摘同键上的新连接(D06)。
|
|
990
1139
|
const key = poolKey(spec);
|