@inobit/pi-instabox 0.0.0-stage → 0.1.0
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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +214 -2
- package/README.zh-CN.md +215 -0
- package/package.json +64 -3
- package/src/boxes.ts +158 -0
- package/src/cli.ts +440 -0
- package/src/config.ts +310 -0
- package/src/devtools.ts +257 -0
- package/src/entry-render.ts +42 -0
- package/src/index.ts +1141 -0
- package/src/log.ts +203 -0
- package/src/pool.ts +651 -0
- package/src/remote.ts +678 -0
- package/src/resolve.ts +216 -0
- package/src/schema.ts +370 -0
- package/src/session-target.ts +100 -0
- package/src/template.ts +449 -0
- package/src/tools.ts +322 -0
package/src/pool.ts
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @inobit/pi-instabox — per-box 远端客户端池 + 唤醒链(方案 §4.4 D4、§4.7、§5.1、T11)。
|
|
3
|
+
*
|
|
4
|
+
* 契约:
|
|
5
|
+
* - **一台 box 一个连接**:池里的客户端只要还活着(`!isDead()`)就复用;`dead` 或不在池里
|
|
6
|
+
* 才新建。复用不查 `ls`:状态只在确实要建连时读一次(否则每次工具调用都多一轮 CLI)。
|
|
7
|
+
* `remote.ts` 的客户端是「单次连接一实例、终态不自动重连」,重建责任在本模块。
|
|
8
|
+
* - **先看状态,再唤醒,最后建连**(用户裁决的取舍:不依赖服务端在请求里唤醒):
|
|
9
|
+
* `get` 先 `listBoxes({ force: true })` 查一次状态,`state === "running"` 直接建连;
|
|
10
|
+
* 其余(`suspended` / `suspending` / `resuming` / 未知态)才进唤醒链。
|
|
11
|
+
* - **唤醒链 `wake` → `start` → `resume`**:`wake` 覆盖自动暂停(实测 6s 左右成功);
|
|
12
|
+
* 用户手动 suspend/stop 的箱 `wake` 会**退出码 0 但 stderr 自报**
|
|
13
|
+
* `machine_not_running` / `start it with …`(`cli.ts` 已按 stderr 判失败)→ 退 `start`,
|
|
14
|
+
* 再失败退 `resume`。每步之前 `onProgress("正在唤醒 <box>…")`,单步上限取 `timeouts.wakeMs`
|
|
15
|
+
* (`wake`)/ `timeouts.startMs`(`start`、`resume`),失败都把该步 `stderr` 尾部带进错误文本。
|
|
16
|
+
* - **准备阶段共享一个截止时间**({@link PrepareBudget}):一次 `get` 从查状态开始就建立
|
|
17
|
+
* `deadline = clock() + timeouts.prepareMs`,每一步(`wake`/`start`/`resume`、确认轮询、建连)
|
|
18
|
+
* 实际超时都是 `min(单步上限, 剩余预算)`,退避与轮询的等待也按剩余收窄。因此**卡住的一步
|
|
19
|
+
* 不会把总时长顶成各步上限之和**(那曾是最多 8 分钟的由来);预算耗尽就回
|
|
20
|
+
* `prepare timed out after <prepareMs>ms (box "…", step: wake|start|resume|confirm|connect)`,
|
|
21
|
+
* 并带上该步 `stderr` 尾部。两个固有限制(有意为之):`listBoxes` 的预算由列表器自己把关
|
|
22
|
+
* (`LIST_BOXES_TIMEOUT_MS`),本模块只能在它返回后按剩余预算决定是否继续;`initialize` 的
|
|
23
|
+
* 超时在建客户端时就固定了,所以这里用「进建连前先看剩余预算」的截止判定,而不是临时改它。
|
|
24
|
+
* - **每步之后都确认 `running`**(方案 §4.4 / R4):命令退 0 也可能只是「已提交」,服务端状态
|
|
25
|
+
* 才是判据。因此以 {@link WAKE_POLL_INTERVAL_MS}(1s)间隔、带 `force: true` 重查 `ls`,直到
|
|
26
|
+
* `state === "running"`。**命令成功时**的确认预算取该步剩余的 `min(单步上限, 准备阶段剩余)`
|
|
27
|
+
* (且不越过截止时间),超时就把当时的 `state` 带进失败记录并继续下一命令;**命令已报失败
|
|
28
|
+
* 时只做一次即时采样**(不空等整步预算——用户手动暂停的箱 `wake` 会立刻报
|
|
29
|
+
* `machine_not_running`,空等 `wakeMs` 只会拖慢退到 `start`),采样到 `running` 仍算成功。
|
|
30
|
+
* 确认通过后才建连。
|
|
31
|
+
* - **`allowWake === false`(`session_start` 的后台路径)**:非 `running` 时**不跑**
|
|
32
|
+
* `wake`/`start`/`resume`,直接回 `{ ok: false, error }`(内部带 `reason: "not-running"`,
|
|
33
|
+
* 供 {@link Pool.get} 判断能否让后来允许唤醒的调用重跑),也不报唤醒进度。
|
|
34
|
+
* - **冷却期不可自愈**:平台文案是
|
|
35
|
+
* `sandbox X is cooling down until <RFC3339>; try again after cooldown`,命中
|
|
36
|
+
* {@link COOLDOWN_RE}(`is cooling down until`)时**不换下一条命令、也不重试连接**,直接报错
|
|
37
|
+
* 并保留原文时间戳(stderr 里若另有 `retryAfterSeconds`/`Retry-After` 也一并透出)。
|
|
38
|
+
* - **网关唤醒中的兜底**:SSH 网关自己只等 15 秒,没等到就拒连并回
|
|
39
|
+
* `machine … is waking up; try again in a few seconds`。正常路径下先 `wake` 已避免它,但状态
|
|
40
|
+
* 与网关仍可能不同步,因此命中 {@link WAKING_UP_RE} 时按 {@link WAKE_RETRY_DELAYS_MS}
|
|
41
|
+
* (5s/10s/15s,共 3 次)**退避重试连接**;其余建连失败原样报握手失败。
|
|
42
|
+
* - 状态与箱名**只来自注入的 `listBoxes`**(T15 传 `createBoxLister(...).list`,其签名天然
|
|
43
|
+
* 可赋值);本模块不读配置、不自己跑 `ls`。`ls` 失败/箱不存在都回错误,不猜状态。
|
|
44
|
+
* - 建客户端后立刻 `initialize(signal)`,**只在握手成功时入池**:失败即 `close()` 丢弃并返回
|
|
45
|
+
* 错误,下次 `get` 重建(失败驱逐)。建连期间被 `dispose` 的连接同样丢弃(per-box 代数
|
|
46
|
+
* 校验),不留孤儿进程。
|
|
47
|
+
* - 同一 box 的并发 `get` 共享同一个 in-flight Promise(先到者的 `opts` 决定
|
|
48
|
+
* `signal`/`allowWake`/`onProgress`),不会为同一 box 起两条 ssh。唯一例外:共享到的结果
|
|
49
|
+
* 是 `reason: "not-running"` 而本次 `allowWake !== false` 时,本次自己重跑一遍——否则
|
|
50
|
+
* 后台拉取的「不唤醒」结论会劫持本该唤醒的工具调用。
|
|
51
|
+
* - `dispose` 先 `close()` 再删表,且**不抛**(`close()` 的异常被吞掉:关停不是用户可见失败)。
|
|
52
|
+
* 它**不是终态**:池之后照常可用,也**不清**在飞的 `get`——在飞的那次连接靠代数校验发现
|
|
53
|
+
* 自己被抛弃,`close()` 后回 `{ ok: false, error: "… pool was disposed while connecting" }`。
|
|
54
|
+
* 因此 `dispose` 之后的第一次 `get` 可能立刻拿到这个 doomed 结果,重试即成功。
|
|
55
|
+
* `size()` 只数已握手入池的连接(在飞的不计),`dispose` 后为 0。
|
|
56
|
+
* - **埋点**:`pool.prepare` 一次 `get` 一条(`{ box, ok, ms, step?, timeoutMs? }`,成功 info /
|
|
57
|
+
* 失败 warn)——`ms` 是准备阶段(查状态 + 唤醒链)本身的耗时,`step`/`timeoutMs` 是报错的
|
|
58
|
+
* 那一步与它拿到的预算;`pool.wake` 每条命令一条并带该步实际的 `timeoutMs`,两者合起来就能
|
|
59
|
+
* 看出「每步用了多少预算、总共用了多久」。
|
|
60
|
+
* - 绝不 throw 到会话层:所有失败都是 `{ ok: false, error }`。
|
|
61
|
+
* - 注入面:`exec` / `listBoxes` / `clientFactory` / `clock` / `sleep` / `onProgress` 全部可替;
|
|
62
|
+
* 单测不 spawn 真进程、不碰真实 CLI、不等真实时间。
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
import type { BoxInfo, ListBoxesResult } from "./boxes.ts";
|
|
66
|
+
import { type CliOutcome, type ExecFn, resume, start, wake } from "./cli.ts";
|
|
67
|
+
import type { InstaboxTimeouts } from "./config.ts";
|
|
68
|
+
import { logEvent } from "./log.ts";
|
|
69
|
+
import { type RemoteClient, createRemoteClient } from "./remote.ts";
|
|
70
|
+
|
|
71
|
+
/** CLI 可执行文件名(与 `cli.ts` 一致,argv[0],不经 shell)。 */
|
|
72
|
+
const INSTABOX_BIN = "instabox";
|
|
73
|
+
|
|
74
|
+
/** 远端桌面工具 MCP server 的可执行文件名(`instabox ssh <box> -- <它> mcp`)。 */
|
|
75
|
+
const COMPUTER_USE_BIN = "instabox-computer-use";
|
|
76
|
+
|
|
77
|
+
/** `ls` 的 `state` 里唯一可用的值;其余(含空串=未知态)都算「还没起来」。 */
|
|
78
|
+
const RUNNING_STATE = "running";
|
|
79
|
+
|
|
80
|
+
/** 唤醒命令成功后确认 `running` 的轮询间隔(方案 §4.4:1s 间隔重新 `ls`)。 */
|
|
81
|
+
export const WAKE_POLL_INTERVAL_MS = 1_000;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 网关唤醒中的重试间隔(毫秒):5s / 10s / 15s,共 3 次,退避合计 30s。
|
|
85
|
+
* 每次重试都是一次完整的新连接(`initialize`),成功即入池;耗尽才报错。
|
|
86
|
+
* 退避也吃准备预算:剩余不足就不睡,直接按「准备阶段超时」报错。
|
|
87
|
+
*/
|
|
88
|
+
export const WAKE_RETRY_DELAYS_MS: readonly number[] = [5_000, 10_000, 15_000];
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 「网关还在唤醒」的可重试错误:SSH 网关的自动唤醒**只等 15 秒**,没等到就拒连并回
|
|
92
|
+
* `machine … is waking up; try again in a few seconds`。正常路径先 `wake` 已避免它,这里只是
|
|
93
|
+
* 状态与网关不同步时的兜底:不 `start`、不 `resume`,退避重试连接。
|
|
94
|
+
*/
|
|
95
|
+
const WAKING_UP_RE = /is waking up|try again in a few seconds/i;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 冷却期:真实文案是 `sandbox X is cooling down until <RFC3339>; try again after cooldown`。
|
|
99
|
+
* 不可自愈——换下一条命令与重连都白费,只报错并保留原文时间。
|
|
100
|
+
*/
|
|
101
|
+
const COOLDOWN_RE = /is cooling down until/i;
|
|
102
|
+
|
|
103
|
+
/** `retryAfterSeconds` 的两种出现形态:JSON 字段,或 `retry after 30`/`Retry-After: 30` 文本。 */
|
|
104
|
+
const RETRY_AFTER_JSON_RE = /"retryAfterSeconds"\s*:\s*(\d+)/i;
|
|
105
|
+
const RETRY_AFTER_TEXT_RE = /retry[- ]?after\s*(?:seconds?\s*)?[:=]?\s*(\d+)/i;
|
|
106
|
+
|
|
107
|
+
/** 内部失败标记:只有 `allowWake === false` 的「箱不在 running」带它,供 `get` 决定是否重跑。 */
|
|
108
|
+
const NOT_RUNNING_REASON = "not-running";
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 唤醒链:`wake`(自动暂停)→ `start`(手动 suspend/stop)→ `resume`(兜底)。
|
|
112
|
+
* 顺序固定,每步失败都退到下一条。
|
|
113
|
+
*/
|
|
114
|
+
const WAKE_COMMANDS = ["wake", "start", "resume"] as const;
|
|
115
|
+
|
|
116
|
+
/** 唤醒链的一步。 */
|
|
117
|
+
type WakeCommand = (typeof WAKE_COMMANDS)[number];
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 准备阶段的步骤名:唤醒链三条命令 + 确认轮询 + 建连。
|
|
121
|
+
* 错误文案与埋点共用这一种拼写(`prepare timed out … (step: <它>)`)。
|
|
122
|
+
*/
|
|
123
|
+
type PrepareStep = WakeCommand | "confirm" | "connect";
|
|
124
|
+
|
|
125
|
+
/** `get` 的结果:失败分支必带可直接展示的 `error`(绝不 throw)。 */
|
|
126
|
+
export type PoolGetResult = { ok: true; client: RemoteClient } | { ok: false; error: string };
|
|
127
|
+
|
|
128
|
+
/** 准备阶段的失败面:`step`/`timeoutMs` 只在「预算耗尽」时出现(错误文案与埋点共用)。 */
|
|
129
|
+
type PrepareFailure = { ok: false; error: string; step?: PrepareStep; timeoutMs?: number };
|
|
130
|
+
|
|
131
|
+
/** 准备阶段(查状态 + 唤醒链)的结果:`ok: true` 只表示「箱已在 running」。 */
|
|
132
|
+
type PrepareResult = { ok: true } | PrepareFailure;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 内部结果:比 {@link PoolGetResult} 多一个 `reason`(后台路径拒唤醒)与准备阶段的
|
|
136
|
+
* `step`/`timeoutMs`。只用于 `get` 内部流转;对外返回类型仍是 `PoolGetResult`。
|
|
137
|
+
*/
|
|
138
|
+
type ConnectResult = { ok: true; client: RemoteClient } | (PrepareFailure & { reason?: typeof NOT_RUNNING_REASON });
|
|
139
|
+
|
|
140
|
+
/** 单次 `get` 的选项。 */
|
|
141
|
+
export interface PoolGetOptions {
|
|
142
|
+
/** 取消信号:透传给唤醒链的 CLI 命令、确认轮询与 `initialize` */
|
|
143
|
+
signal?: AbortSignal;
|
|
144
|
+
/** 本次的唤醒进度回调(覆盖 {@link PoolDeps.onProgress},用于工具的 `onUpdate`) */
|
|
145
|
+
onProgress?: (message: string) => void;
|
|
146
|
+
/** `false` = `session_start` 路径:**绝不**唤醒,箱不在 `running` 就报错(默认允许唤醒) */
|
|
147
|
+
allowWake?: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `listBoxes` 的选项:`force` 跳过 `ls` 缓存(开头的状态查询与确认阶段都必须带,否则 5s TTL
|
|
152
|
+
* 会看到陈旧状态),`signal` 是建议字段(`createBoxLister(...).list` 忽略它;本模块每轮自查 abort)。
|
|
153
|
+
*/
|
|
154
|
+
export interface PoolListOptions {
|
|
155
|
+
force?: boolean;
|
|
156
|
+
signal?: AbortSignal;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 注入的 box 列表来源;`createBoxLister(...).list` 的签名天然可赋值。 */
|
|
160
|
+
export type ListBoxesFn = (opts?: PoolListOptions) => Promise<ListBoxesResult>;
|
|
161
|
+
|
|
162
|
+
/** 注入的等待函数(毫秒);默认真实 `setTimeout`,单测换成即时返回,绝不等真实时间。 */
|
|
163
|
+
export type SleepFn = (ms: number) => Promise<void>;
|
|
164
|
+
|
|
165
|
+
/** 池的注入面。 */
|
|
166
|
+
export interface PoolDeps {
|
|
167
|
+
/** per-box 客户端工厂;默认 {@link createRemoteClient} + `instabox ssh <box> -- … mcp` */
|
|
168
|
+
clientFactory?: (box: string) => RemoteClient;
|
|
169
|
+
/** 注入的 CLI 执行函数(唤醒链的 `wake`/`start`/`resume`) */
|
|
170
|
+
exec: ExecFn;
|
|
171
|
+
/** 注入的 box 列表(T15 传 `createBoxLister(...).list`);失败即本模块失败,不降级 */
|
|
172
|
+
listBoxes: ListBoxesFn;
|
|
173
|
+
/** 准备阶段预算:`prepareMs` 是总截止时间,`wakeMs`/`startMs` 只是单步上限 */
|
|
174
|
+
timeouts: InstaboxTimeouts;
|
|
175
|
+
/** 注入的时钟(毫秒):确认阶段的预算判定 + 错误文本里的耗时;默认 `Date.now` */
|
|
176
|
+
clock?: () => number;
|
|
177
|
+
/** 注入的等待(确认轮询的 1s 间隔);默认真实 `setTimeout` */
|
|
178
|
+
sleep?: SleepFn;
|
|
179
|
+
/** 默认唤醒进度出口(被单次 `get` 的 `onProgress` 覆盖) */
|
|
180
|
+
onProgress?: (message: string) => void;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** per-box 客户端池。 */
|
|
184
|
+
export interface Pool {
|
|
185
|
+
/** 取某 box 的可用客户端:复用 / 查状态唤醒 / 建连三选一,失败回 `{ ok: false, error }` */
|
|
186
|
+
get(box: string, opts?: PoolGetOptions): Promise<PoolGetResult>;
|
|
187
|
+
/** 关停并移除某 box 的连接(不给参数时等于 {@link Pool.disposeAll}) */
|
|
188
|
+
dispose(box?: string): void;
|
|
189
|
+
/** 关停并移除所有连接 */
|
|
190
|
+
disposeAll(): void;
|
|
191
|
+
/** 当前池里可用(已握手)的连接数 */
|
|
192
|
+
size(): number;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 本次调用是否已被取消(用一个函数封装:`readonly` 字段的窄化会跨 `await` 残留)。 */
|
|
196
|
+
function isAborted(opts: PoolGetOptions): boolean {
|
|
197
|
+
return opts.signal?.aborted === true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 每一步命令的单步上限:`wake` 只等自动暂停,`start`/`resume` 是整箱冷启。 */
|
|
201
|
+
function commandTimeoutMs(command: WakeCommand, timeouts: InstaboxTimeouts): number {
|
|
202
|
+
return command === "wake" ? timeouts.wakeMs : timeouts.startMs;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 一次 `get` 的准备阶段预算:所有步骤共享同一个截止时间。
|
|
207
|
+
* 每一步的实际超时取 {@link stepTimeout}(`min(单步上限, 剩余预算)`),所以某一步卡住
|
|
208
|
+
* 只会吃掉它自己那一段,总时长不会变成各步上限之和。
|
|
209
|
+
*/
|
|
210
|
+
interface PrepareBudget {
|
|
211
|
+
/** 总预算(毫秒):错误文案与 `pool.prepare` 埋点用 */
|
|
212
|
+
prepareMs: number;
|
|
213
|
+
/** 起点(`clock()` 口径):`pool.prepare` 的 `ms` 从这里算 */
|
|
214
|
+
startedAt: number;
|
|
215
|
+
/** 截止时间(`clock()` 口径):唤醒、确认轮询、建连共用 */
|
|
216
|
+
deadline: number;
|
|
217
|
+
/** 剩余预算(0..prepareMs,不为负) */
|
|
218
|
+
remaining: () => number;
|
|
219
|
+
/** 准备阶段结束时刻(`clock()` 口径;`undefined` = 还没结束) */
|
|
220
|
+
endedAt?: number;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function createPrepareBudget(clock: () => number, prepareMs: number): PrepareBudget {
|
|
224
|
+
const startedAt = clock();
|
|
225
|
+
const deadline = startedAt + prepareMs;
|
|
226
|
+
return { prepareMs, startedAt, deadline, remaining: () => Math.max(0, deadline - clock()) };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 该步的实际超时 = `min(单步上限, 剩余准备预算)`。 */
|
|
230
|
+
function stepTimeout(budget: PrepareBudget, stepCapMs: number): number {
|
|
231
|
+
return Math.min(stepCapMs, budget.remaining());
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 预算耗尽时的统一结果:写明总预算、是哪一步、以及该步的 `stderr` 尾部。 */
|
|
235
|
+
function prepareTimeout(
|
|
236
|
+
box: string,
|
|
237
|
+
step: PrepareStep,
|
|
238
|
+
budget: PrepareBudget,
|
|
239
|
+
timeoutMs: number,
|
|
240
|
+
stderrTail?: string,
|
|
241
|
+
): PrepareFailure & { step: PrepareStep; timeoutMs: number } {
|
|
242
|
+
const detail = stderrTail === undefined ? "" : `; stderr: ${stderrTail}`;
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
step,
|
|
246
|
+
timeoutMs,
|
|
247
|
+
error: `prepare timed out after ${budget.prepareMs}ms (box "${box}", step: ${step})${detail}`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** 唤醒链单步:转调 `cli.ts` 的同名薄封装(不在这里拼 argv)。 */
|
|
252
|
+
function runWakeCommand(
|
|
253
|
+
exec: ExecFn,
|
|
254
|
+
command: WakeCommand,
|
|
255
|
+
box: string,
|
|
256
|
+
timeout: number,
|
|
257
|
+
signal: AbortSignal | undefined,
|
|
258
|
+
): Promise<CliOutcome> {
|
|
259
|
+
if (command === "wake") return wake(exec, box, { timeout, signal });
|
|
260
|
+
if (command === "start") return start(exec, box, { timeout, signal });
|
|
261
|
+
return resume(exec, box, { timeout, signal });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** `unknown` → message 文本(错误文本里不出现 `[object Object]`)。 */
|
|
265
|
+
function messageOf(error: unknown): string {
|
|
266
|
+
return error instanceof Error ? error.message : String(error);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** `state` 的展示形式:空串是「未知」,其余加引号(错误文本用)。 */
|
|
270
|
+
function stateLabel(state: string): string {
|
|
271
|
+
return state === "" ? "unknown" : `"${state}"`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** 候选箱名列表(错误文本用;空列表给更明确的一句话)。 */
|
|
275
|
+
function availableBoxes(boxes: readonly BoxInfo[]): string {
|
|
276
|
+
const names = boxes.map((box) => box.name).filter((name) => name !== "");
|
|
277
|
+
return names.length > 0 ? `available boxes: ${names.join(", ")}` : "instabox ls returned no boxes";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 从失败文本里取 `retryAfterSeconds`(没有就 `undefined`,错误文本里也就不提数字)。 */
|
|
281
|
+
function retryAfterSeconds(text: string): number | undefined {
|
|
282
|
+
const match = RETRY_AFTER_JSON_RE.exec(text) ?? RETRY_AFTER_TEXT_RE.exec(text);
|
|
283
|
+
if (match === null) return undefined;
|
|
284
|
+
const seconds = Number(match[1]);
|
|
285
|
+
return Number.isFinite(seconds) ? seconds : undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** 冷却期错误的可展示形态:原文(含 `until <RFC3339>`)原样保留,附带可透出的重试时间。 */
|
|
289
|
+
function cooldownError(box: string, text: string): string {
|
|
290
|
+
const seconds = retryAfterSeconds(text);
|
|
291
|
+
const retry = seconds === undefined ? "" : `; retryAfterSeconds=${seconds}`;
|
|
292
|
+
return `box "${box}" is cooling down (not retryable, do not start)${retry}: ${text}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** 网关唤醒重试耗尽的形态:说清「已经重试过几次」,并保留平台原文。 */
|
|
296
|
+
function stillWakingUp(box: string, text: string, retries: number): string {
|
|
297
|
+
return `box "${box}": machine did not finish waking up after ${retries} connection retries: ${text}`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** 握手失败的统一形态(非「唤醒中」/非冷却期都走这里)。 */
|
|
301
|
+
function handshakeError(box: string, text: string): string {
|
|
302
|
+
return `box "${box}": remote MCP handshake failed: ${text}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** 默认等待:真实 `setTimeout`(宿主/单测都可整体替换)。 */
|
|
306
|
+
const defaultSleep: SleepFn = (ms) =>
|
|
307
|
+
new Promise<void>((resolve) => {
|
|
308
|
+
globalThis.setTimeout(resolve, ms);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* 建 per-box 客户端池。
|
|
313
|
+
*
|
|
314
|
+
* 取连接的顺序:复用 → 查状态(非 `running` 才 `wake` → `start` → `resume`,每步确认 `running`)
|
|
315
|
+
* → 建连;`allowWake === false` 时非 `running` 直接报错。
|
|
316
|
+
*/
|
|
317
|
+
export function createPool(deps: PoolDeps): Pool {
|
|
318
|
+
const { exec, listBoxes, timeouts } = deps;
|
|
319
|
+
const clock = deps.clock ?? Date.now;
|
|
320
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
321
|
+
const factory =
|
|
322
|
+
deps.clientFactory ??
|
|
323
|
+
((box: string): RemoteClient =>
|
|
324
|
+
createRemoteClient({
|
|
325
|
+
command: INSTABOX_BIN,
|
|
326
|
+
args: ["ssh", box, "--", COMPUTER_USE_BIN, "mcp"],
|
|
327
|
+
timeouts: {
|
|
328
|
+
connect: timeouts.connectMs,
|
|
329
|
+
initialize: timeouts.initializeMs,
|
|
330
|
+
listTools: timeouts.listToolsMs,
|
|
331
|
+
callTool: timeouts.callMs,
|
|
332
|
+
},
|
|
333
|
+
}));
|
|
334
|
+
|
|
335
|
+
/** 已握手可用的连接 */
|
|
336
|
+
const clients = new Map<string, RemoteClient>();
|
|
337
|
+
/** 在飞的 `get`(同一 box 的并发调用共享它;`dispose` 不清它,结果由代数校验兜住) */
|
|
338
|
+
const inflight = new Map<string, Promise<ConnectResult>>();
|
|
339
|
+
/**
|
|
340
|
+
* per-box 代数:`dispose` 递增。建连期间代数变了说明该连接在关停之后才建好,
|
|
341
|
+
* 必须丢弃(否则 `dispose` 之后仍会泄漏一个 ssh 子进程)。
|
|
342
|
+
*/
|
|
343
|
+
const generation = new Map<string, number>();
|
|
344
|
+
|
|
345
|
+
function currentGeneration(box: string): number {
|
|
346
|
+
return generation.get(box) ?? 0;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function bumpGeneration(box: string): void {
|
|
350
|
+
generation.set(box, currentGeneration(box) + 1);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** `close()` 是尽力而为:关停失败不能变成用户可见错误。 */
|
|
354
|
+
function closeQuietly(client: RemoteClient | undefined): void {
|
|
355
|
+
if (client === undefined) return;
|
|
356
|
+
try {
|
|
357
|
+
client.close();
|
|
358
|
+
} catch {
|
|
359
|
+
// 忽略:上层已经在删表,连接对象即将失去引用
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** 关停并移除某 box 的连接(幂等)。 */
|
|
364
|
+
function evict(box: string): void {
|
|
365
|
+
const client = clients.get(box);
|
|
366
|
+
clients.delete(box);
|
|
367
|
+
bumpGeneration(box);
|
|
368
|
+
closeQuietly(client);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function dispose(box?: string): void {
|
|
372
|
+
// 不给参数 = disposeAll:此时箱名字段缺席(`logEvent` 丢掉 `undefined`)
|
|
373
|
+
logEvent("pool.dispose", { box }, "debug");
|
|
374
|
+
if (box !== undefined) {
|
|
375
|
+
evict(box);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
for (const name of new Set([...clients.keys(), ...inflight.keys()])) evict(name);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** 一次 `listBoxes` 调用:异常与失败都归一成 `{ ok: false, error }`。 */
|
|
382
|
+
async function listBoxesOnce(opts: PoolListOptions): Promise<{ ok: true; boxes: BoxInfo[] } | { ok: false; error: string }> {
|
|
383
|
+
try {
|
|
384
|
+
const listed = await listBoxes(opts);
|
|
385
|
+
if (!listed.ok) return { ok: false, error: `cannot list boxes: ${listed.error ?? "unknown error"}` };
|
|
386
|
+
return { ok: true, boxes: listed.boxes };
|
|
387
|
+
} catch (error) {
|
|
388
|
+
return { ok: false, error: `cannot list boxes: ${messageOf(error)}` };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** 状态查询失败/箱不存在都回错误:不猜状态,也不静默降级到唤醒。 */
|
|
393
|
+
async function resolveState(box: string): Promise<{ ok: true; info: BoxInfo } | { ok: false; error: string }> {
|
|
394
|
+
const listed = await listBoxesOnce({ force: true });
|
|
395
|
+
if (!listed.ok) return listed;
|
|
396
|
+
const info = listed.boxes.find((candidate) => candidate.name === box);
|
|
397
|
+
if (info === undefined) {
|
|
398
|
+
return { ok: false, error: `unknown box "${box}"; ${availableBoxes(listed.boxes)}` };
|
|
399
|
+
}
|
|
400
|
+
logEvent("pool.state", { box, state: info.state, desiredState: info.desiredState }, "debug");
|
|
401
|
+
return { ok: true, info };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* 一次建连尝试:新客户端 + `initialize(signal)`。
|
|
406
|
+
* 失败即 `close()` 丢弃(失败驱逐:下次 `get` 重建),并回**原始**错误文本。
|
|
407
|
+
*/
|
|
408
|
+
async function attemptConnect(
|
|
409
|
+
box: string,
|
|
410
|
+
opts: PoolGetOptions,
|
|
411
|
+
): Promise<{ ok: true; client: RemoteClient } | { ok: false; error: string }> {
|
|
412
|
+
const client = factory(box);
|
|
413
|
+
try {
|
|
414
|
+
await client.initialize(opts.signal);
|
|
415
|
+
return { ok: true, client };
|
|
416
|
+
} catch (error) {
|
|
417
|
+
closeQuietly(client);
|
|
418
|
+
return { ok: false, error: messageOf(error) };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* 建连 + 「网关唤醒中」退避重试:`is waking up` / `try again in a few seconds` 是可重试错误
|
|
424
|
+
* (网关只等 15s),按 {@link WAKE_RETRY_DELAYS_MS} 重连,最多 3 次。
|
|
425
|
+
* 返回最后一次的原始失败文本与已重试次数;非「唤醒中」的失败**不重试**,立刻返回。
|
|
426
|
+
* 退避也吃准备预算:剩余不足就不再睡(保留原始错误文本,由调用方按「准备阶段超时」报错)。
|
|
427
|
+
*/
|
|
428
|
+
async function connectWithWakeRetry(
|
|
429
|
+
box: string,
|
|
430
|
+
opts: PoolGetOptions,
|
|
431
|
+
budget: PrepareBudget,
|
|
432
|
+
): Promise<{ ok: true; client: RemoteClient } | { ok: false; error: string; retries: number }> {
|
|
433
|
+
let attempt = await attemptConnect(box, opts);
|
|
434
|
+
let retries = 0;
|
|
435
|
+
for (const delay of WAKE_RETRY_DELAYS_MS) {
|
|
436
|
+
if (attempt.ok || !WAKING_UP_RE.test(attempt.error)) break;
|
|
437
|
+
if (isAborted(opts)) return { ok: false, error: `box "${box}": connect cancelled by caller`, retries };
|
|
438
|
+
const wait = Math.min(delay, budget.remaining());
|
|
439
|
+
if (wait <= 0) break;
|
|
440
|
+
await sleep(wait);
|
|
441
|
+
if (isAborted(opts)) return { ok: false, error: `box "${box}": connect cancelled by caller`, retries };
|
|
442
|
+
retries += 1;
|
|
443
|
+
attempt = await attemptConnect(box, opts);
|
|
444
|
+
}
|
|
445
|
+
if (attempt.ok) return attempt;
|
|
446
|
+
return { ok: false, error: attempt.error, retries };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* 唤醒命令之后的确认(方案 §4.4 / R4):`force: true` 每 1s 重查,直到 `running`。
|
|
451
|
+
* 第一次采样不等 1s(命令刚返回,箱子可能已经好了);预算是该步的 `min(单步上限, 剩余
|
|
452
|
+
* 准备预算)`,且绝不越过准备阶段的截止时间——最后一觉按剩余时间收窄,因此轮询不会把
|
|
453
|
+
* 总时长顶出预算。`budgetMs === 0`(命令已报失败)时只做一次即时采样。
|
|
454
|
+
*/
|
|
455
|
+
async function confirmRunning(
|
|
456
|
+
box: string,
|
|
457
|
+
opts: PoolGetOptions,
|
|
458
|
+
budget: PrepareBudget,
|
|
459
|
+
budgetMs: number,
|
|
460
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
461
|
+
const startedAt = clock();
|
|
462
|
+
const until = Math.min(startedAt + budgetMs, budget.deadline);
|
|
463
|
+
for (;;) {
|
|
464
|
+
if (isAborted(opts)) return { ok: false, error: `box "${box}": wake cancelled by caller` };
|
|
465
|
+
const listed = await listBoxesOnce({ force: true, signal: opts.signal });
|
|
466
|
+
if (!listed.ok) return { ok: false, error: `${listed.error} (while confirming box "${box}")` };
|
|
467
|
+
const info = listed.boxes.find((candidate) => candidate.name === box);
|
|
468
|
+
if (info === undefined) {
|
|
469
|
+
return { ok: false, error: `unknown box "${box}"; ${availableBoxes(listed.boxes)}` };
|
|
470
|
+
}
|
|
471
|
+
if (info.state === RUNNING_STATE) return { ok: true };
|
|
472
|
+
const left = until - clock();
|
|
473
|
+
if (left <= 0) {
|
|
474
|
+
return {
|
|
475
|
+
ok: false,
|
|
476
|
+
error: `box "${box}" did not reach "${RUNNING_STATE}" within ${until - startedAt}ms of the wake attempt (state ${stateLabel(info.state)})`,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
await sleep(Math.min(WAKE_POLL_INTERVAL_MS, left));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** `allowWake === false` 的拒答:说清状态,并给出两条可执行补救。 */
|
|
484
|
+
function notRunning(box: string, info: BoxInfo): ConnectResult {
|
|
485
|
+
return {
|
|
486
|
+
ok: false,
|
|
487
|
+
reason: NOT_RUNNING_REASON,
|
|
488
|
+
error: `box "${box}" is ${stateLabel(info.state)}, not running; this path does not wake boxes — run /instabox-use ${box} (or "instabox start ${box}") first`,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* 唤醒链:`wake` → `start` → `resume`,每步之后都确认 `running`(命令退 0 也可能只是提交成功)。
|
|
494
|
+
* 每步超时取 `min(单步上限, 剩余准备预算)`:成功 → 按该步剩余的预算轮询;失败 → 只做一
|
|
495
|
+
* 次即时采样(不空等,见文件头契约)。命令失败、确认超时都记一笔并继续下一条命令;全失败
|
|
496
|
+
* 才报错。冷却期直接终止(不可自愈);预算耗尽立即报 `prepare timed out` 并带上该步 stderr
|
|
497
|
+
* 尾部(不再进下一条命令,否则总时长又会变成各步上限之和)。
|
|
498
|
+
* 只在状态非 `running` 且 `allowWake !== false` 时调用。
|
|
499
|
+
*/
|
|
500
|
+
async function ensureRunning(box: string, opts: PoolGetOptions, budget: PrepareBudget): Promise<PrepareResult> {
|
|
501
|
+
const progress = opts.onProgress ?? deps.onProgress;
|
|
502
|
+
const failures: string[] = [];
|
|
503
|
+
for (const command of WAKE_COMMANDS) {
|
|
504
|
+
if (isAborted(opts)) return { ok: false, error: `box "${box}": wake cancelled by caller` };
|
|
505
|
+
const stepCapMs = commandTimeoutMs(command, timeouts);
|
|
506
|
+
const timeoutMs = stepTimeout(budget, stepCapMs);
|
|
507
|
+
if (timeoutMs <= 0) return prepareTimeout(box, command, budget, 0);
|
|
508
|
+
progress?.(`正在唤醒 ${box}…`);
|
|
509
|
+
const startedAt = clock();
|
|
510
|
+
const outcome = await runWakeCommand(exec, command, box, timeoutMs, opts.signal);
|
|
511
|
+
const ms = clock() - startedAt;
|
|
512
|
+
// 每一步都记:`wake` → `start` → `resume` 的退化路径靠这个时间线排查;
|
|
513
|
+
// `timeoutMs` 是该步实际拿到的预算(clamp 之后),与 `ms` 一起看就知道谁吃掉了预算
|
|
514
|
+
logEvent(
|
|
515
|
+
"pool.wake",
|
|
516
|
+
{ box, step: command, ok: outcome.ok, ms, timeoutMs },
|
|
517
|
+
outcome.ok ? "info" : "warn",
|
|
518
|
+
);
|
|
519
|
+
if (!outcome.ok) {
|
|
520
|
+
// 冷却期不可自愈:不确认、不换下一条命令,直接透出原文时间
|
|
521
|
+
if (COOLDOWN_RE.test(outcome.error)) return { ok: false, error: cooldownError(box, outcome.error) };
|
|
522
|
+
failures.push(`${outcome.error} (after ${ms}ms)`);
|
|
523
|
+
}
|
|
524
|
+
// 成功才值得等;失败只即时采样一次,避免用户手动暂停的箱白等一整步预算
|
|
525
|
+
const confirmBudgetMs = outcome.ok ? stepTimeout(budget, stepCapMs) : 0;
|
|
526
|
+
const confirmed = await confirmRunning(box, opts, budget, confirmBudgetMs);
|
|
527
|
+
if (confirmed.ok) return { ok: true };
|
|
528
|
+
if (isAborted(opts)) return confirmed;
|
|
529
|
+
failures.push(confirmed.error);
|
|
530
|
+
// 预算耗尽:不再退到下一条命令,直接报准备阶段超时(带该步 stderr 尾部)
|
|
531
|
+
if (budget.remaining() <= 0) return prepareTimeout(box, command, budget, timeoutMs, outcome.stderrTail);
|
|
532
|
+
}
|
|
533
|
+
return { ok: false, error: `could not wake box "${box}": ${failures.join("; ")}` };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** 握手成功后的入池:建连期间被 `dispose` 过的连接必须丢弃(不留孤儿进程)。 */
|
|
537
|
+
function adopt(box: string, stamp: number, client: RemoteClient): ConnectResult {
|
|
538
|
+
if (currentGeneration(box) !== stamp) {
|
|
539
|
+
closeQuietly(client);
|
|
540
|
+
return { ok: false, error: `box "${box}": pool was disposed while connecting` };
|
|
541
|
+
}
|
|
542
|
+
clients.set(box, client);
|
|
543
|
+
return { ok: true, client };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* 准备阶段:查状态,非 `running` 才进唤醒链(`allowWake === false` 直接拒答)。
|
|
548
|
+
* 整段共享 {@link PrepareBudget} 的截止时间;`ls` 自己的预算由列表器把关,这里只能在它
|
|
549
|
+
* 返回后按剩余预算决定后续步骤。
|
|
550
|
+
*/
|
|
551
|
+
async function prepareBox(box: string, opts: PoolGetOptions, budget: PrepareBudget): Promise<PrepareResult> {
|
|
552
|
+
const state = await resolveState(box);
|
|
553
|
+
if (!state.ok) return state;
|
|
554
|
+
if (state.info.state === RUNNING_STATE) return { ok: true };
|
|
555
|
+
if (opts.allowWake === false) return notRunning(box, state.info);
|
|
556
|
+
return ensureRunning(box, opts, budget);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function connectBox(box: string, opts: PoolGetOptions, budget: PrepareBudget): Promise<ConnectResult> {
|
|
560
|
+
const stamp = currentGeneration(box);
|
|
561
|
+
try {
|
|
562
|
+
// 先看状态:running 直接连;非 running 才进唤醒链(allowWake=false 直接拒答)
|
|
563
|
+
const prepared = await prepareBox(box, opts, budget);
|
|
564
|
+
// 准备阶段到此结束:`pool.prepare` 的 `ms` 只量这一段,不含建连握手
|
|
565
|
+
budget.endedAt = clock();
|
|
566
|
+
if (!prepared.ok) return prepared;
|
|
567
|
+
// 预算已耗尽就不再 spawn:`initialize` 的超时在建客户端时就固定了,这里只能拦住起点
|
|
568
|
+
const connectTimeoutMs = stepTimeout(budget, timeouts.connectMs);
|
|
569
|
+
if (connectTimeoutMs <= 0) return prepareTimeout(box, "connect", budget, 0);
|
|
570
|
+
// 确认 running 之后才建连;`is waking up` 是网关侧的兜底(理论上先 wake 后不会遇到)
|
|
571
|
+
const attempt = await connectWithWakeRetry(box, opts, budget);
|
|
572
|
+
if (attempt.ok) return adopt(box, stamp, attempt.client);
|
|
573
|
+
if (budget.remaining() <= 0) {
|
|
574
|
+
return prepareTimeout(box, "connect", budget, connectTimeoutMs, attempt.error);
|
|
575
|
+
}
|
|
576
|
+
if (WAKING_UP_RE.test(attempt.error)) {
|
|
577
|
+
return { ok: false, error: stillWakingUp(box, attempt.error, attempt.retries) };
|
|
578
|
+
}
|
|
579
|
+
if (COOLDOWN_RE.test(attempt.error)) {
|
|
580
|
+
return { ok: false, error: cooldownError(box, attempt.error) };
|
|
581
|
+
}
|
|
582
|
+
return { ok: false, error: handshakeError(box, attempt.error) };
|
|
583
|
+
} catch (error) {
|
|
584
|
+
// 兜底:任何意外异常都不外抛(池的失败面只有 `{ ok: false, error }`)
|
|
585
|
+
return { ok: false, error: `box "${box}": ${messageOf(error)}` };
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* 一次建连尝试的埋点包装:`pool.prepare`(准备阶段本身的 `ms` + 失败时报错的那一步与它
|
|
591
|
+
* 拿到的预算)与 `pool.connect`(整段建连)各一条,返回值与错误文案不动。
|
|
592
|
+
*/
|
|
593
|
+
async function connect(box: string, opts: PoolGetOptions): Promise<ConnectResult> {
|
|
594
|
+
const budget = createPrepareBudget(clock, timeouts.prepareMs);
|
|
595
|
+
const result = await connectBox(box, opts, budget);
|
|
596
|
+
const endedAt = budget.endedAt ?? clock();
|
|
597
|
+
logEvent(
|
|
598
|
+
"pool.prepare",
|
|
599
|
+
{
|
|
600
|
+
box,
|
|
601
|
+
ok: result.ok,
|
|
602
|
+
ms: endedAt - budget.startedAt,
|
|
603
|
+
step: result.ok ? undefined : result.step,
|
|
604
|
+
timeoutMs: result.ok ? undefined : result.timeoutMs,
|
|
605
|
+
},
|
|
606
|
+
result.ok ? "info" : "warn",
|
|
607
|
+
);
|
|
608
|
+
logEvent("pool.connect", {
|
|
609
|
+
box,
|
|
610
|
+
ok: result.ok,
|
|
611
|
+
ms: clock() - budget.startedAt,
|
|
612
|
+
error: result.ok ? undefined : result.error,
|
|
613
|
+
});
|
|
614
|
+
return result;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** 起一次建连并登记为 in-flight(同一 box 的下一个调用会共享它)。 */
|
|
618
|
+
function startConnect(box: string, opts: PoolGetOptions): Promise<ConnectResult> {
|
|
619
|
+
const started = connect(box, opts);
|
|
620
|
+
inflight.set(box, started);
|
|
621
|
+
// 登记者自己也等待完成后的清理:只有还挂着「自己」时才删,不误删重跑出来的新条目
|
|
622
|
+
return started.finally(() => {
|
|
623
|
+
if (inflight.get(box) === started) inflight.delete(box);
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function get(box: string, opts: PoolGetOptions = {}): Promise<PoolGetResult> {
|
|
628
|
+
const existing = clients.get(box);
|
|
629
|
+
if (existing !== undefined) {
|
|
630
|
+
if (!existing.isDead()) {
|
|
631
|
+
logEvent("pool.reuse", { box }, "debug");
|
|
632
|
+
return Promise.resolve({ ok: true, client: existing });
|
|
633
|
+
}
|
|
634
|
+
evict(box);
|
|
635
|
+
}
|
|
636
|
+
const pending = inflight.get(box);
|
|
637
|
+
if (pending === undefined) return startConnect(box, opts);
|
|
638
|
+
return pending.then((result) => {
|
|
639
|
+
// 共享到的若是后台路径的「不唤醒」结论,而本次允许唤醒:自己重跑一遍
|
|
640
|
+
if (result.ok || result.reason !== NOT_RUNNING_REASON || opts.allowWake === false) return result;
|
|
641
|
+
return startConnect(box, opts);
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
return {
|
|
646
|
+
get,
|
|
647
|
+
dispose,
|
|
648
|
+
disposeAll: () => dispose(),
|
|
649
|
+
size: () => clients.size,
|
|
650
|
+
};
|
|
651
|
+
}
|