@coclaw/openclaw-coclaw 0.18.0 → 0.19.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.
@@ -0,0 +1,310 @@
1
+ /**
2
+ * 纯内存版 FBQ-API 兼容容器
3
+ *
4
+ * 阶段 1 用作 RpcDcSender 的前置缓冲,替换原 `RpcSendQueue` 中的"容器层"职责(admission +
5
+ * overflow 边沿状态机 + close 汇总)。阶段 2 再把本模块替换为 `FileBackedQueue`,因接口对齐,
6
+ * 替换近乎一行 import 改动。
7
+ *
8
+ * 与 FBQ 的差异:
9
+ * - 不引入 fs;`diskBytes / writtenBytes` 永为 0,`spilled / fsBroken` 永为 false
10
+ * - admission 仅基于 `memBudget`;命中 `bypassAdmission(jsonStr)` 时即使队列满也接收(保留
11
+ * `RpcSendQueue` 的 agent run 白名单豁免行为)
12
+ * - 内部携带 overflow-start/-end 边沿状态机和 close 汇总日志(搬自原 `RpcSendQueue`)
13
+ *
14
+ * 契约:`enqueue / __nextIter / destroy / clear` 内部任何分支都不得因 logger / remoteLog / onDrop /
15
+ * bypassAdmission 自身抛而传染调用方——所有外部调用均经过 safe wrapper(try/catch)。
16
+ *
17
+ * 使用方式(消费侧):
18
+ * ```js
19
+ * for await (const str of queue) { await sender.send(str); }
20
+ * ```
21
+ * 调用 `queue.destroy()` 后 iterator 在下一轮返回 `{ done: true }`。
22
+ */
23
+
24
+ import { remoteLog } from '../remote-log.js';
25
+ import { createMutex } from './mutex.js';
26
+
27
+ /** 默认内存预算:与原 RpcSendQueue 的 MAX_QUEUE_BYTES 对齐 */
28
+ export const DEFAULT_MEM_BUDGET = 10 * 1024 * 1024;
29
+
30
+ const ID_RE = /^[A-Za-z0-9._-]+$/;
31
+
32
+ // 惰性压缩阈值(与 FBQ 一致):head 越过 64 且占数组一半以上时切片回收
33
+ const COMPACT_HEAD_THRESHOLD = 64;
34
+
35
+ class MemoryQueue {
36
+ /**
37
+ * @param {object} opts
38
+ * @param {string} opts.id - 队列标识(仅用于日志 tag/路径校验对齐 FBQ)
39
+ * @param {number} [opts.memBudget=10MB] - 内存软上限;queueBytes >= memBudget 且非 bypass 时新消息 drop
40
+ * @param {number} [opts.maxMessageBytes=Infinity] - 单条硬上限;超过即 drop(bypass 也不豁免)
41
+ * @param {(reason: string, size: number) => void} [opts.onDrop] - 拒入队回调
42
+ * @param {{ warn?: Function, info?: Function, error?: Function }} [opts.logger=console]
43
+ * @param {(jsonStr: string) => boolean} [opts.bypassAdmission] - 白名单谓词,命中则 admission 豁免
44
+ * @param {string} [opts.tag] - 日志前缀(如 connId),缺省不带
45
+ */
46
+ constructor(opts) {
47
+ const {
48
+ id,
49
+ memBudget = DEFAULT_MEM_BUDGET,
50
+ maxMessageBytes = Infinity,
51
+ onDrop,
52
+ logger,
53
+ bypassAdmission,
54
+ tag,
55
+ } = opts ?? {};
56
+
57
+ if (!id || typeof id !== 'string') throw new TypeError('id is required');
58
+ if (id === '.' || id === '..' || !ID_RE.test(id)) {
59
+ throw new TypeError('id contains invalid characters');
60
+ }
61
+ if (!Number.isFinite(memBudget) || memBudget <= 0) {
62
+ throw new TypeError('memBudget must be a finite positive number');
63
+ }
64
+ // Infinity 也合法;只挡 NaN / 非数字 / 非正数
65
+ if (maxMessageBytes !== Infinity && (!Number.isFinite(maxMessageBytes) || maxMessageBytes <= 0)) {
66
+ throw new TypeError('maxMessageBytes must be Infinity or a finite positive number');
67
+ }
68
+
69
+ this.id = id;
70
+ this.memBudget = memBudget;
71
+ this.maxMessageBytes = maxMessageBytes;
72
+ this.onDrop = onDrop;
73
+ this.logger = logger ?? console;
74
+ this.bypassAdmission = typeof bypassAdmission === 'function' ? bypassAdmission : null;
75
+ this.tag = tag ?? '';
76
+
77
+ // 单文件 ring-ish 结构:head 指针 + 数组;shift 为 O(1) 摊销
78
+ this.memQueue = [];
79
+ this.head = 0;
80
+ this.memBytes = 0;
81
+
82
+ // 构造即可用:MemoryQueue 不碰 fs。`init()` 仍保留为 no-op-but-callable,便于
83
+ // 阶段 2 替换为 FileBackedQueue 时调用方仅需在创建处加一行 `await queue.init()`。
84
+ this.initialized = true;
85
+ this.destroyed = false;
86
+ this.waiters = [];
87
+ this.mutex = createMutex();
88
+
89
+ // drop 统计 + overflow 边沿状态机
90
+ this.droppedCount = 0;
91
+ this.droppedBytes = 0;
92
+ this.queueOverflowActive = false;
93
+ }
94
+
95
+ /**
96
+ * 异步初始化(接口对齐 FBQ)。MemoryQueue 不碰 fs,构造时 initialized 已置 true,本函数为 no-op。
97
+ * 阶段 2 切到 FBQ 时此函数承担实际的残留清理。幂等。
98
+ */
99
+ async init() {
100
+ // 不持锁也无副作用;保留 await 与 mutex 交互结构是为了让阶段 2 切换 FBQ 时无需改外部时序
101
+ return await this.mutex.withLock(async () => { /* no-op */ });
102
+ }
103
+
104
+ async [Symbol.asyncDispose]() {
105
+ await this.destroy();
106
+ }
107
+
108
+ /**
109
+ * 入队一条字符串。
110
+ * - 队列满(memBytes >= memBudget)且未命中 bypassAdmission → onDrop + 返回 false
111
+ * 首次进入溢出态打 overflow-start(warn + remoteLog),持续期间静默累加
112
+ * - 否则入队 + 返回 true(包括"单条 overshoot":当前 memBytes < memBudget,但本条很大)
113
+ *
114
+ * @param {string} jsonStr
115
+ * @returns {Promise<boolean>}
116
+ */
117
+ async enqueue(jsonStr) {
118
+ return await this.mutex.withLock(async () => {
119
+ if (this.destroyed) return false;
120
+ if (typeof jsonStr !== 'string') throw new TypeError('jsonStr must be a string');
121
+
122
+ const size = Buffer.byteLength(jsonStr, 'utf8');
123
+
124
+ // per-message 硬上限:bypass 也不豁免。对齐 sender 端 MAX_SINGLE_MSG_BYTES 检查,
125
+ // 避免大帧先入队再被 sender 拒,导致 memBytes 异常膨胀(特别是 sender 阻塞期间)。
126
+ if (size > this.maxMessageBytes) {
127
+ this.droppedCount += 1;
128
+ this.droppedBytes += size;
129
+ this.__dispatchDrop('oversize', size);
130
+ this.__safeWarn(`drop reason=oversize size=${size} cap=${this.maxMessageBytes}`);
131
+ return false;
132
+ }
133
+
134
+ // admission:与原 RpcSendQueue 行为对齐——按当前已积压字节判断(不含本条 size),
135
+ // 允许"单条 overshoot":上一条消息把 queueBytes 顶到 < MAX 但 >= MAX 之间任一值时
136
+ // 仍能入队;下一次再有非白名单消息才会被 drop。
137
+ if (this.memBytes >= this.memBudget && !this.__isBypass(jsonStr)) {
138
+ this.droppedCount += 1;
139
+ this.droppedBytes += size;
140
+ this.__dispatchDrop('queue-full', size);
141
+ // 仅状态翻转点打 log,避免 DC 卡死时刷屏
142
+ if (!this.queueOverflowActive) {
143
+ this.queueOverflowActive = true;
144
+ this.__safeWarn(`overflow-start queueBytes=${this.memBytes}`);
145
+ this.__safeRemoteLog(`rpc-queue.overflow-start${this.__tagSuffix()} queueBytes=${this.memBytes}`);
146
+ }
147
+ return false;
148
+ }
149
+
150
+ this.memQueue.push(jsonStr);
151
+ this.memBytes += size;
152
+ this.__wakeOne();
153
+ return true;
154
+ });
155
+ }
156
+
157
+ /**
158
+ * 当前快照,用于诊断 dump。形态与 FBQ 对齐 + 阶段 1 私有诊断字段。
159
+ * @returns {{
160
+ * memCount: number, memBytes: number, diskBytes: number, writtenBytes: number,
161
+ * spilled: boolean, fsBroken: boolean,
162
+ * droppedCount: number, droppedBytes: number, queueOverflowActive: boolean
163
+ * }}
164
+ */
165
+ stats() {
166
+ return {
167
+ memCount: this.memQueue.length - this.head,
168
+ memBytes: this.memBytes,
169
+ diskBytes: 0,
170
+ writtenBytes: 0,
171
+ spilled: false,
172
+ fsBroken: false,
173
+ droppedCount: this.droppedCount,
174
+ droppedBytes: this.droppedBytes,
175
+ queueOverflowActive: this.queueOverflowActive,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * 清空数据但保留实例可用,重置 drop 统计与 overflow 状态。
181
+ */
182
+ async clear() {
183
+ return await this.mutex.withLock(async () => {
184
+ if (this.destroyed) return;
185
+ this.memQueue = [];
186
+ this.head = 0;
187
+ this.memBytes = 0;
188
+ this.droppedCount = 0;
189
+ this.droppedBytes = 0;
190
+ this.queueOverflowActive = false;
191
+ });
192
+ }
193
+
194
+ /**
195
+ * 关闭队列:唤醒所有 waiter(让 iterator 返回 done)、汇总 drop/residual log。幂等。
196
+ */
197
+ async destroy() {
198
+ return await this.mutex.withLock(async () => {
199
+ if (this.destroyed) return;
200
+ this.destroyed = true;
201
+
202
+ const residual = this.memQueue.length - this.head;
203
+ const residualBytes = this.memBytes;
204
+
205
+ // 唤醒所有等待者,让它们在下一轮循环看到 destroyed 并返回 done
206
+ const toWake = this.waiters.splice(0);
207
+ for (const w of toWake) w.resolve();
208
+
209
+ this.memQueue = [];
210
+ this.head = 0;
211
+ this.memBytes = 0;
212
+
213
+ if (this.droppedCount > 0 || residual > 0) {
214
+ this.__safeRemoteLog(
215
+ `rpc-queue.close${this.__tagSuffix()} dropped=${this.droppedCount} droppedBytes=${this.droppedBytes} residualChunks=${residual} residualBytes=${residualBytes}`,
216
+ );
217
+ }
218
+ });
219
+ }
220
+
221
+ [Symbol.asyncIterator]() {
222
+ const self = this;
223
+ return {
224
+ next() { return self.__nextIter(); },
225
+ return() { return Promise.resolve({ done: true, value: undefined }); },
226
+ [Symbol.asyncIterator]() { return this; },
227
+ };
228
+ }
229
+
230
+ async __nextIter() {
231
+ while (true) {
232
+ let waitPromise = null;
233
+ const result = await this.mutex.withLock(async () => {
234
+ if (this.memQueue.length - this.head > 0) {
235
+ const item = this.memQueue[this.head];
236
+ this.memQueue[this.head] = undefined;
237
+ this.head += 1;
238
+ this.memBytes -= Buffer.byteLength(item, 'utf8');
239
+
240
+ // 惰性压缩:避免 head 一直向前、数组永不回收
241
+ if (this.head > COMPACT_HEAD_THRESHOLD && this.head * 2 >= this.memQueue.length) {
242
+ this.memQueue = this.memQueue.slice(this.head);
243
+ this.head = 0;
244
+ }
245
+
246
+ // 满 → 未满 状态翻转:与 overflow-start 对称,含累计 dropped
247
+ if (this.queueOverflowActive && this.memBytes < this.memBudget) {
248
+ this.queueOverflowActive = false;
249
+ this.__safeInfo(`overflow-end dropped=${this.droppedCount} droppedBytes=${this.droppedBytes}`);
250
+ this.__safeRemoteLog(
251
+ `rpc-queue.overflow-end${this.__tagSuffix()} dropped=${this.droppedCount} droppedBytes=${this.droppedBytes}`,
252
+ );
253
+ }
254
+ return { value: item, done: false };
255
+ }
256
+ if (this.destroyed) return { done: true, value: undefined };
257
+ waitPromise = new Promise((resolve, reject) => {
258
+ this.waiters.push({ resolve, reject });
259
+ });
260
+ return null;
261
+ });
262
+ if (result !== null) return result;
263
+ await waitPromise;
264
+ }
265
+ }
266
+
267
+ __wakeOne() {
268
+ if (this.waiters.length > 0) {
269
+ const w = this.waiters.shift();
270
+ w.resolve();
271
+ }
272
+ }
273
+
274
+ __isBypass(jsonStr) {
275
+ if (!this.bypassAdmission) return false;
276
+ try {
277
+ return Boolean(this.bypassAdmission(jsonStr));
278
+ } catch {
279
+ // bypass 谓词自身抛 → 视为非白名单(最安全的回退;保守 drop 而非误入队)
280
+ return false;
281
+ }
282
+ }
283
+
284
+ __dispatchDrop(reason, size) {
285
+ try {
286
+ this.onDrop?.(reason, size);
287
+ } catch (err) {
288
+ // onDrop 自身抛是调用方的 bug;不能传染给 enqueue 契约
289
+ this.__safeWarn(`onDrop threw: ${err?.message}`);
290
+ }
291
+ }
292
+
293
+ __tagSuffix() {
294
+ return this.tag ? ` ${this.tag}` : '';
295
+ }
296
+
297
+ __safeWarn(msg) {
298
+ try { this.logger.warn?.(`[rpc-queue${this.__tagSuffix()}] ${msg}`); } catch { /* logger 自身坏了也不能让 enqueue 抛 */ }
299
+ }
300
+
301
+ __safeInfo(msg) {
302
+ try { this.logger.info?.(`[rpc-queue${this.__tagSuffix()}] ${msg}`); } catch { /* logger 自身坏了也不能让 enqueue/__nextIter 抛 */ }
303
+ }
304
+
305
+ __safeRemoteLog(text) {
306
+ try { remoteLog(text); } catch { /* 防御性:remoteLog 当前同步路径不抛,未来若变化此 wrapper 兜底 */ }
307
+ }
308
+ }
309
+
310
+ export { MemoryQueue };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 判断一条 JSON 字符串是否为带 runId 的 RPC 响应(队列满时白名单豁免谓词)。
3
+ *
4
+ * 命中条件(仅看顶层):`type === 'res'` 且 `payload.runId` 为 truthy。
5
+ * 设计取舍:硬编码识别、不维护方法白名单表。该条件主要为覆盖 OpenClaw `agent` 二阶段 res
6
+ * 与 `agent.wait` 全部分支(accepted/ok/error/timeout/race/dedupe);同时也会顺带豁免
7
+ * `chat.send` 等其他顶层带 `runId` 的响应——这类 rsp 极小,加白无副作用。
8
+ * 解析失败或不命中按非白名单处理。
9
+ *
10
+ * @param {string} jsonStr - 待发送的 RPC 帧 JSON 字符串
11
+ * @returns {boolean} 命中白名单返回 true;解析失败或不命中返回 false
12
+ */
13
+ export function isAgentRunResponse(jsonStr) {
14
+ try {
15
+ const parsed = JSON.parse(jsonStr);
16
+ return parsed?.type === 'res' && Boolean(parsed?.payload?.runId);
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
@@ -39,6 +39,11 @@ export function buildChunks(jsonStr, maxMessageSize, getNextMsgId) {
39
39
 
40
40
  const msgId = getNextMsgId();
41
41
  const totalChunks = Math.ceil(fullBytes.byteLength / chunkPayloadSize);
42
+ // 边界保护:极小 maxMessageSize 下数学上可能产生 > MAX_CHUNKS_PER_MSG 的 chunk 数量;
43
+ // 接收端会在第 MAX_CHUNKS_PER_MSG+1 块拒绝,整条消息将无法重组。早拒避免无效发送。
44
+ if (totalChunks > MAX_CHUNKS_PER_MSG) {
45
+ throw new Error(`message of ${fullBytes.byteLength} bytes with maxMessageSize=${maxMessageSize} would produce ${totalChunks} chunks, exceeds ${MAX_CHUNKS_PER_MSG}`);
46
+ }
42
47
  const chunks = new Array(totalChunks);
43
48
 
44
49
  for (let i = 0; i < totalChunks; i++) {
@@ -57,7 +62,7 @@ export function buildChunks(jsonStr, maxMessageSize, getNextMsgId) {
57
62
 
58
63
  /**
59
64
  * 按需分片并发送消息(薄包装:buildChunks + dc.send)
60
- * 注意:无应用层流控;生产路径请使用 RpcSendQueue
65
+ * 注意:无应用层流控;生产路径请使用 MemoryQueue + RpcDcSender
61
66
  * @param {object} dc - DataChannel
62
67
  * @param {string} jsonStr - 已序列化的 JSON 字符串
63
68
  * @param {number} maxMessageSize - 对端声明的 maxMessageSize
@@ -93,6 +98,14 @@ export function createReassembler(onComplete, opts = {}) {
93
98
  function feed(data) {
94
99
  // string = 普通消息,直接交付
95
100
  if (typeof data === 'string') {
101
+ // 边界保护:peer 可能发送超过单条消息硬上限的字符串(绕过 sender 端 50MB 检查)。
102
+ // 注意必须用 byteLength 而非 .length —— 后者是 UTF-16 code unit 数,CJK 字符
103
+ // 1 char = 1 unit 但 UTF-8 占 3 byte,按 .length 比较会让多字节内容绕过上限近 3 倍。
104
+ const byteLen = Buffer.byteLength(data, 'utf8');
105
+ if (byteLen > MAX_REASSEMBLY_BYTES) {
106
+ logger?.warn?.(`[dc-chunking] string frame exceeded ${MAX_REASSEMBLY_BYTES} bytes (got ${byteLen}), discarding`);
107
+ return;
108
+ }
96
109
  onComplete(data);
97
110
  return;
98
111
  }
@@ -108,6 +121,12 @@ export function createReassembler(onComplete, opts = {}) {
108
121
  const msgId = buf.readUInt32BE(1);
109
122
  const payload = buf.subarray(HEADER_SIZE);
110
123
 
124
+ // 未知 flag 直接拒绝,避免污染同 msgId 的 pending entry
125
+ if (flag !== FLAG_BEGIN && flag !== FLAG_MIDDLE && flag !== FLAG_END) {
126
+ logger?.warn?.(`[dc-chunking] unknown flag 0x${flag.toString(16)} for msgId=${msgId}, discarding`);
127
+ return;
128
+ }
129
+
111
130
  if (flag === FLAG_BEGIN) {
112
131
  // 若已有同 msgId 的未完成重组,丢弃旧的
113
132
  if (pending.has(msgId)) {
@@ -0,0 +1,178 @@
1
+ /**
2
+ * rpc DataChannel 紧贴发送器(async 阻塞式)
3
+ *
4
+ * 阶段 1 从原 `RpcSendQueue` 中拆出:仅保留 DC 紧贴部分——分片(buildChunks)、bufferedAmount
5
+ * 背压、错误协议。不再持有任何缓冲队列;容器层(admission / overflow / drop 统计)由
6
+ * `MemoryQueue` 承担。
7
+ *
8
+ * 调用形态:典型与 MemoryQueue + for-await 联用。
9
+ * ```js
10
+ * for await (const str of queue) {
11
+ * try { await sender.send(str); }
12
+ * catch (err) {
13
+ * if (err.code === 'SENDER_CLOSED') break;
14
+ * logger.warn?.(`rpc-dc.send-failed code=${err.code} size=${str.length}`);
15
+ * }
16
+ * }
17
+ * ```
18
+ *
19
+ * 错误协议(统一抛错,no silent return):
20
+ * - `SENDER_CLOSED` —— sender 关闭 / DC 非 open / dc.send 抛
21
+ * - `MESSAGE_OVERSIZED` —— 单条 payload > 50 MB
22
+ * - `BUILD_CHUNKS_FAILED` —— buildChunks 抛(如 maxMessageSize 太小)
23
+ *
24
+ * 背压:bufferedAmount >= HIGH 时 await `bufferedamountlow` 事件再继续;close 期间内部 waiter
25
+ * 全部 reject(SENDER_CLOSED)。
26
+ *
27
+ * `maxMessageSize` 公开字段:ICE restart 后由调用方热更新(PC 重建会重置实例,无需此字段;
28
+ * 同 connId 的 ICE restart 不重建 sender,仅热更)。
29
+ */
30
+
31
+ import { buildChunks } from './dc-chunking.js';
32
+ import { remoteLog } from '../remote-log.js';
33
+
34
+ /** 高水位:`dc.bufferedAmount >= HIGH` 时 send 阻塞等 BAL */
35
+ export const DC_HIGH_WATER_MARK = 1024 * 1024; // 1 MB
36
+ /** 低水位:用于 `dc.bufferedAmountLowThreshold`,触发 `bufferedamountlow` 事件 */
37
+ export const DC_LOW_WATER_MARK = 256 * 1024; // 256 KB
38
+ /** 单条 payload 硬上限:对齐 dc-chunking.js MAX_REASSEMBLY_BYTES */
39
+ export const MAX_SINGLE_MSG_BYTES = 50 * 1024 * 1024; // 50 MB
40
+
41
+ class RpcDcSender {
42
+ /**
43
+ * @param {object} opts
44
+ * @param {object} opts.dc - DataChannel 实例(需支持 send / bufferedAmount / readyState)
45
+ * @param {number} opts.maxMessageSize - 对端 SDP 声明的 a=max-message-size(公开字段,可热更新)
46
+ * @param {() => number} opts.getNextMsgId - 分片 msgId 生成器
47
+ * @param {{ warn?: Function, info?: Function, error?: Function }} [opts.logger=console]
48
+ * @param {string} [opts.tag] - 日志前缀(如 connId)
49
+ */
50
+ constructor({ dc, maxMessageSize, getNextMsgId, logger, tag } = {}) {
51
+ if (!dc) throw new Error('RpcDcSender: dc is required');
52
+ this.dc = dc;
53
+ this.maxMessageSize = maxMessageSize;
54
+ this.getNextMsgId = getNextMsgId;
55
+ this.logger = logger ?? console;
56
+ this.tag = tag ?? '';
57
+ this.closed = false;
58
+ /** @type {{ resolve: () => void, reject: (err: Error) => void }[]} */
59
+ this.balWaiters = [];
60
+ }
61
+
62
+ /**
63
+ * 发送一条 JSON 字符串。Async 阻塞式:bufferedAmount 顶到 HIGH 时 await BAL 再继续。
64
+ * 失败均抛出带 `code` 的 Error;调用方按 code 处理。
65
+ *
66
+ * @param {string} jsonStr
67
+ * @returns {Promise<void>}
68
+ * @throws {Error & { code: 'SENDER_CLOSED' | 'MESSAGE_OVERSIZED' | 'BUILD_CHUNKS_FAILED' }}
69
+ */
70
+ async send(jsonStr) {
71
+ this.__assertOpen();
72
+
73
+ const payloadBytes = Buffer.byteLength(jsonStr, 'utf8');
74
+
75
+ if (payloadBytes > MAX_SINGLE_MSG_BYTES) {
76
+ this.__safeWarn(`drop reason=single-msg-oversize size=${payloadBytes} cap=${MAX_SINGLE_MSG_BYTES}`);
77
+ throw makeErr('MESSAGE_OVERSIZED', `payload exceeds ${MAX_SINGLE_MSG_BYTES} bytes (size=${payloadBytes})`);
78
+ }
79
+
80
+ let chunks;
81
+ try {
82
+ chunks = buildChunks(jsonStr, this.maxMessageSize, this.getNextMsgId);
83
+ } catch (err) {
84
+ const errMsg = err?.message ?? String(err);
85
+ this.__safeWarn(`drop reason=build-chunks-failed size=${payloadBytes} maxMessageSize=${this.maxMessageSize} err=${errMsg}`);
86
+ this.__safeRemoteLog(`rpc-dc-sender.build-chunks-failed${this.__tagSuffix()} size=${payloadBytes} maxMessageSize=${this.maxMessageSize} err=${errMsg}`);
87
+ const wrap = makeErr('BUILD_CHUNKS_FAILED', `buildChunks failed: ${errMsg}`);
88
+ wrap.cause = err;
89
+ throw wrap;
90
+ }
91
+
92
+ if (!chunks) {
93
+ // 不分片:单条 string 整体发送
94
+ await this.__sendOne(jsonStr);
95
+ return;
96
+ }
97
+ // 分片路径:逐 chunk 阻塞发送,保证同消息的 chunk 连续
98
+ for (const chunk of chunks) {
99
+ await this.__sendOne(chunk);
100
+ }
101
+ }
102
+
103
+ /** 由外部 `dc.onbufferedamountlow` 事件触发,唤醒所有等 BAL 的 waiter */
104
+ onBufferedAmountLow() {
105
+ const toWake = this.balWaiters.splice(0);
106
+ for (const w of toWake) w.resolve();
107
+ }
108
+
109
+ /**
110
+ * 关闭发送器:reject 所有 pending waiter(带 SENDER_CLOSED)。幂等。
111
+ * 阻塞中的 send 会在下一刻抛 SENDER_CLOSED;后续 send 调用立即抛。
112
+ */
113
+ close() {
114
+ if (this.closed) return;
115
+ this.closed = true;
116
+ const toReject = this.balWaiters.splice(0);
117
+ for (const w of toReject) {
118
+ w.reject(makeErr('SENDER_CLOSED', 'sender closed'));
119
+ }
120
+ }
121
+
122
+ async __sendOne(payload) {
123
+ await this.__waitForRoom();
124
+ // BAL 与 close 的窄缝:BAL 触发时 onBufferedAmountLow 已 splice 走 waiter 并 resolve;
125
+ // 若紧接着 close() 介入,splice 看到空数组、无 waiter 可 reject,唤醒后的 continuation
126
+ // 仍会跑到这里。重检 closed 以保 "closed 后不再写 dc" 的不变量;readyState 变化交由
127
+ // dc.send 抛 InvalidStateError 时下面 catch 捕获兜底。
128
+ if (this.closed) {
129
+ throw makeErr('SENDER_CLOSED', 'sender closed during BAL wait');
130
+ }
131
+ try {
132
+ this.dc.send(payload);
133
+ } catch (err) {
134
+ this.__safeWarn(`dc.send failed: ${err?.message}`);
135
+ const wrap = makeErr('SENDER_CLOSED', `dc.send failed: ${err?.message}`);
136
+ wrap.cause = err;
137
+ throw wrap;
138
+ }
139
+ }
140
+
141
+ async __waitForRoom() {
142
+ // 立刻检查 closed/readyState
143
+ if (this.closed || this.dc.readyState !== 'open') {
144
+ throw makeErr('SENDER_CLOSED', 'sender closed or dc not open');
145
+ }
146
+ if (this.dc.bufferedAmount < DC_HIGH_WATER_MARK) return;
147
+ // 阻塞等 onBufferedAmountLow 唤醒(或 close reject)
148
+ return await new Promise((resolve, reject) => {
149
+ this.balWaiters.push({ resolve, reject });
150
+ });
151
+ }
152
+
153
+ __assertOpen() {
154
+ if (this.closed || this.dc.readyState !== 'open') {
155
+ throw makeErr('SENDER_CLOSED', 'sender closed or dc not open');
156
+ }
157
+ }
158
+
159
+ __tagSuffix() {
160
+ return this.tag ? ` ${this.tag}` : '';
161
+ }
162
+
163
+ __safeWarn(msg) {
164
+ try { this.logger.warn?.(`[rpc-dc-sender${this.__tagSuffix()}] ${msg}`); } catch { /* logger 自身坏了不能让 send 抛非协议错 */ }
165
+ }
166
+
167
+ __safeRemoteLog(text) {
168
+ try { remoteLog(text); } catch { /* 防御性兜底 */ }
169
+ }
170
+ }
171
+
172
+ function makeErr(code, message) {
173
+ const err = new Error(message);
174
+ err.code = code;
175
+ return err;
176
+ }
177
+
178
+ export { RpcDcSender };