@hyzyn/dsh-docker 0.9.2 → 0.9.4

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/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
- * 通道**,典型成因是同一连接的 MaxSessions 被长流占满——连接本身是健康的。把它当传输
258
- * 错误会泄漏健康连接(摘出池却不关闭,keepalive 一直养着),还会把 `describeExecError`
259
- * 补的可操作文案藏掉(重连后新连接额度是空的,命令反而成功)。见 D07。
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;**传输层**错误时丢掉连接、重连一次(见 `isTransportError`)。
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 (!/Channel open failure|open failed/i.test(message))
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
- * 通道**,典型成因是同一连接的 MaxSessions 被长流占满——连接本身是健康的。把它当传输
648
- * 错误会泄漏健康连接(摘出池却不关闭,keepalive 一直养着),还会把 `describeExecError`
649
- * 补的可操作文案藏掉(重连后新连接额度是空的,命令反而成功)。见 D07。
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
- const rt = this.conns.get(key);
728
- if (rt !== undefined)
729
- rt.inflight += 1;
835
+ /*
836
+ * 短命令闸门(D150):先排队再开通道,超出的调用等前一条收尾。
837
+ * 「开门」也算在占用里——若在拿到名额与开门之间放走另一个调用,两条通道会同时落在同一条
838
+ * 连接上,而额度只剩两条余量(长流上限 8 / MaxSessions 10),那正是要防的事。
839
+ */
840
+ const releaseSlot = await this.gateFor(key).acquire();
730
841
  try {
731
- return await new Promise((resolve, reject) => {
732
- const stdoutSink = new ByteSink(maxBytes, options?.keepTail === true);
733
- const stderrSink = new ByteSink(maxBytes, options?.keepTail === true);
734
- const stdoutDecoder = new StringDecoder('utf8');
735
- const stderrDecoder = new StringDecoder('utf8');
736
- let timedOut = false;
737
- let settled = false;
738
- let timer = null;
739
- const finish = (code) => {
740
- if (settled)
741
- return;
742
- settled = true;
743
- if (timer !== null)
744
- clearTimeout(timer);
745
- const current = this.conns.get(key);
746
- if (current !== undefined)
747
- current.lastUsed = Date.now();
748
- resolve({
749
- code,
750
- stdout: stdoutSink.decode(stdoutDecoder),
751
- stderr: stderrSink.decode(stderrDecoder),
752
- timedOut,
753
- truncated: stdoutSink.truncated || stderrSink.truncated,
754
- durationMs: Date.now() - started,
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
- // 定时器放在 finish **之后**(D112):超时除了打断远端命令,还要**直接 settle**。
758
- // 原先只 signal('KILL') + close(),若通道静默不响应(既不 emit 'close' 也不 emit
759
- // 'error'),promise 永不落定 → finally 里的 inflight 减不掉 → 该连接对
760
- // shouldRecycleConn 永远是「在途」,sweeper 再也回收不了它。
761
- // settled 守卫保证与随后的 'close' 事件不会重复 resolve(幂等)。
762
- timer = setTimeout(() => {
763
- timedOut = true;
764
- try {
765
- channel.signal('KILL');
766
- }
767
- catch {
768
- /* 远端可能已结束 */
769
- }
770
- channel.close();
771
- finish(null);
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
- if (options?.input !== undefined)
791
- channel.end(options.input);
792
- });
912
+ }
913
+ finally {
914
+ if (rt !== undefined)
915
+ rt.inflight = Math.max(0, rt.inflight - 1);
916
+ }
793
917
  }
794
918
  finally {
795
- if (rt !== undefined)
796
- rt.inflight = Math.max(0, rt.inflight - 1);
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;**传输层**错误时丢掉连接、重连一次(见 `isTransportError`)。
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
- if (attempt === 0 && isTransportError(message)) {
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);