@coclaw/openclaw-coclaw 0.26.4 → 0.26.5

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/index.js CHANGED
@@ -16,6 +16,7 @@ import { createFileHandler } from './src/file-manager/handler.js';
16
16
  import { abortAgentRun } from './src/agent-abort.js';
17
17
  import { decideCancelResponse } from './src/agent-cancel-heuristic.js';
18
18
  import { remoteLog } from './src/remote-log.js';
19
+ import { normalizeAgentId } from './src/utils/agent-id.js';
19
20
  import { registerProviderAuthHandlers } from './src/provider-auth/index.js';
20
21
  import { reconcilePortalModels } from './src/provider-auth/reconcile.js';
21
22
  import { registerModelDefaultHandlers } from './src/model-default/index.js';
@@ -417,6 +418,9 @@ const plugin = {
417
418
  // 取消前一个 enroll(与 doBind/doUnbind 共享 helper)
418
419
  cancelActiveEnroll();
419
420
  const abortController = new AbortController();
421
+ // 故意提前 set:enrollClaw 抛错(如 ALREADY_BOUND)时此 controller 会残留,不在 catch 重置——
422
+ // signal 无 live waiter,abort 死 controller 是 no-op,唯一后果是下次多一行 cancel 日志;
423
+ // defer/catch-reset 两轮补丁均不可靠已 revert,故保持原样。
420
424
  activeEnrollAbort = abortController;
421
425
 
422
426
  const serverUrl = params?.serverUrl ?? api.pluginConfig?.serverUrl;
@@ -461,10 +465,13 @@ const plugin = {
461
465
 
462
466
  api.registerGatewayMethod('nativeui.sessions.listAll', async ({ params, respond }) => {
463
467
  try {
464
- const agentId = params?.agentId?.trim?.() || 'main';
468
+ const agentId = normalizeAgentId(params);
465
469
  // best-effort ensure:失败不阻断 listAll
466
470
  try { await ensureAgentSession(agentId); }
467
471
  catch {}
472
+ // listAll 刻意传原始 params:manager.listAll 内部用与 normalizeAgentId 同一套 trim 逻辑
473
+ // 自行解析 agentId(见 manager.js)——非字符串已被上面 normalizeAgentId 先抛 INVALID_INPUT
474
+ // 拦死,带空白的两侧都归一到同一 agent、返回同一列表,无分叉,故此处不另归一、无需守卫。
468
475
  respond(true, await manager.listAll(params ?? {}));
469
476
  }
470
477
  catch (err) {
@@ -536,7 +543,7 @@ const plugin = {
536
543
 
537
544
  api.registerGatewayMethod('coclaw.topics.create', async ({ params, respond }) => {
538
545
  try {
539
- const agentId = params?.agentId?.trim?.() || 'main';
546
+ const agentId = normalizeAgentId(params);
540
547
  // 确保该 agent 的 topics 已加载
541
548
  if (!topicManager.__cache.has(agentId)) {
542
549
  await topicManager.load(agentId);
@@ -551,7 +558,7 @@ const plugin = {
551
558
 
552
559
  api.registerGatewayMethod('coclaw.topics.list', async ({ params, respond }) => {
553
560
  try {
554
- const agentId = params?.agentId?.trim?.() || 'main';
561
+ const agentId = normalizeAgentId(params);
555
562
  if (!topicManager.__cache.has(agentId)) {
556
563
  await topicManager.load(agentId);
557
564
  }
@@ -583,7 +590,7 @@ const plugin = {
583
590
  respondInvalid(respond, 'topicId required');
584
591
  return;
585
592
  }
586
- const agentId = params?.agentId?.trim?.() || 'main';
593
+ const agentId = normalizeAgentId(params);
587
594
  // 直接复用 session-manager 的 get(),topicId 即 sessionId
588
595
  respond(true, await manager.get({ agentId, sessionId: topicId }));
589
596
  }
@@ -661,7 +668,7 @@ const plugin = {
661
668
 
662
669
  api.registerGatewayMethod('coclaw.chatHistory.list', async ({ params, respond }) => {
663
670
  try {
664
- const agentId = params?.agentId?.trim?.() || 'main';
671
+ const agentId = normalizeAgentId(params);
665
672
  const sessionKey = params?.sessionKey?.trim?.();
666
673
  if (!sessionKey) {
667
674
  respondInvalid(respond, 'sessionKey required');
@@ -686,7 +693,7 @@ const plugin = {
686
693
  respondInvalid(respond, 'sessionId required');
687
694
  return;
688
695
  }
689
- const agentId = params?.agentId?.trim?.() || 'main';
696
+ const agentId = normalizeAgentId(params);
690
697
  const limit = params?.limit;
691
698
  respond(true, await manager.getById({ agentId, sessionId, limit }));
692
699
  }
@@ -865,6 +872,7 @@ const plugin = {
865
872
  // 并发控制:取消前一个 enroll(与 RPC 路径共享 helper)
866
873
  cancelActiveEnroll();
867
874
  const abortController = new AbortController();
875
+ // 同上:故意提前 set,残留仅日志噪音、abort 为 no-op(理由见 RPC handler 处)。
868
876
  activeEnrollAbort = abortController;
869
877
 
870
878
  const serverUrl = options.server ?? api.pluginConfig?.serverUrl;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.26.4",
3
+ "version": "0.26.5",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw plugin for remote chat over WebRTC. Run `openclaw coclaw enroll` after install.",
@@ -326,8 +326,10 @@ export class ChatHistoryManager {
326
326
  * RPC 契约:`coclaw.chatHistory.list` 直接透传本返回值,不做服务端过滤;调用方
327
327
  * (UI / 其它消费者)按 `archivedAt != null` 自行过滤未归档头与孤儿历史段。
328
328
  *
329
- * **返回值是 cache 引用,调用方禁止 mutate**(不要 splice / sort / 改 item 字段);
330
- * RPC handler 立刻 JSON 序列化所以无副作用,进程内消费者若需要修改请先 deep copy。
329
+ * 输出按「活跃头(archivedAt 为空)保序居首 + 已归档段按 archivedAt 降序(新→旧)」重排。
330
+ * **返回的是新数组(filter 产生的副本),不是 cache 引用**,故重排不污染 cache;但数组内
331
+ * 的 item 仍是 cache 共享对象,**调用方禁止改 item 字段**。RPC handler 立刻 JSON 序列化
332
+ * 所以无副作用,进程内消费者若需要修改 item 请先 deep copy。
331
333
  *
332
334
  * @param {{ agentId: string, sessionKey: string }} params
333
335
  * @returns {Promise<{ history: { sessionId: string, archivedAt?: number }[] }>}
@@ -336,7 +338,21 @@ export class ChatHistoryManager {
336
338
  await this.__reloadFromDisk(agentId);
337
339
  const store = this.__getStore(agentId);
338
340
  const history = Array.isArray(store[sessionKey]) ? store[sessionKey] : [];
339
- return { history };
341
+ // 分区:宽松判空 == null 把活跃头(archivedAt 实测是 undefined)与已归档段分开。
342
+ // filter 保序且产生新数组——绝不原地 sort 污染 cache(list 是无锁读路径,被污染的
343
+ // cache 可能被后续写盘误持久化)。
344
+ // 先剔除 null/非对象脏元素(仅外部篡改磁盘 JSON 才会出现;正常写盘只 unshift 对象)。
345
+ // 与写盘侧 __sanitizeAllSessionKeys(!item || typeof item !== 'object' 跳过)姿态一致。
346
+ // 丢弃而非保留:保留 null 没有任何消费者受益,反而会被 UI loadNextHistorySession
347
+ // 的无守卫 candidate.sessionId 解引用抛未捕获异常、打断历史加载。
348
+ const valid = history.filter((it) => it && typeof it === 'object');
349
+ const heads = valid.filter((it) => it.archivedAt == null);
350
+ const archived = valid.filter((it) => it.archivedAt != null);
351
+ // 全序安全比较器:任意值规整成有限数,坏值(NaN/非数字串/对象/Infinity)一律沉到 0、
352
+ // 落最旧端。哨兵必须用 0 不能用 -Infinity(两个 -Infinity 相减 = NaN 会复活乱序 bug)。
353
+ const ms = (x) => { const n = Number(x); return Number.isFinite(n) ? n : 0; };
354
+ archived.sort((a, b) => ms(b.archivedAt) - ms(a.archivedAt));
355
+ return { history: [...heads, ...archived] };
340
356
  }
341
357
 
342
358
  /**
@@ -73,6 +73,8 @@ export async function bindClaw({ code, serverUrl }, deps = {}) {
73
73
  }
74
74
  catch (writeErr) {
75
75
  // 本地 writeCfg 失败 → 回滚 server 端,避免产生孤儿 claw(与 unbind 强制不容错的红线对称)
76
+ // unbindServer 是 async,调用恒返回 promise(连同步异常也转 rejected),.catch 必挂上;
77
+ // 故意不防"同步 throw"——只有非 async mock 才造得出、生产不可达,真发生也被外层 catch 兜住。
76
78
  await unbindServer({ baseUrl, token: data.token }).catch(() => {
77
79
  // 回滚失败不掩盖原因;用户根据原始 writeErr.message 排查,
78
80
  // server 端孤儿可通过下次 enroll/bind 时 401/404/410 再清理
@@ -3,6 +3,7 @@ import fsp from 'node:fs/promises';
3
3
  import nodePath from 'node:path';
4
4
  import { randomUUID, randomBytes } from 'node:crypto';
5
5
  import { remoteLog } from '../remote-log.js';
6
+ import { normalizeAgentId } from '../utils/agent-id.js';
6
7
 
7
8
  // --- 常量 ---
8
9
 
@@ -142,7 +143,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
142
143
  }
143
144
 
144
145
  async function listFiles(params) {
145
- const agentId = params?.agentId?.trim?.() || 'main';
146
+ const agentId = normalizeAgentId(params);
146
147
  const userPath = params?.path ?? ''; /* c8 ignore next -- ?? fallback */
147
148
  const workspaceDir = await resolveWorkspace(agentId);
148
149
  const resolved = await validatePath(workspaceDir, userPath || '.', pathDeps);
@@ -194,7 +195,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
194
195
  }
195
196
 
196
197
  async function deleteFile(params) {
197
- const agentId = params?.agentId?.trim?.() || 'main';
198
+ const agentId = normalizeAgentId(params);
198
199
  const userPath = params?.path;
199
200
  if (!userPath) {
200
201
  const err = new Error('path is required');
@@ -237,7 +238,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
237
238
  }
238
239
 
239
240
  async function mkdirOp(params) {
240
- const agentId = params?.agentId?.trim?.() || 'main';
241
+ const agentId = normalizeAgentId(params);
241
242
  const userPath = params?.path;
242
243
  if (!userPath) {
243
244
  const err = new Error('path is required');
@@ -251,7 +252,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
251
252
  }
252
253
 
253
254
  async function createFile(params) {
254
- const agentId = params?.agentId?.trim?.() || 'main';
255
+ const agentId = normalizeAgentId(params);
255
256
  const userPath = params?.path;
256
257
  if (!userPath) {
257
258
  const err = new Error('path is required');
@@ -276,6 +277,10 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
276
277
 
277
278
  // 确保父目录存在
278
279
  await _mkdir(nodePath.dirname(resolved), { recursive: true });
280
+ // 并发同名创建走 last-writer-wins(后写胜出):故意用普通 writeFile,不用排他创建 'wx'。
281
+ // 两个并发 create 都建空文件、都成功,结果一个空文件、无数据丢失(对"创建"语义更友好)。
282
+ // 已评估并弃用 'wx'+ALREADY_EXISTS(2026-06-22 用户裁定)。极窄残留:create 与同名上传
283
+ // 并发时 lstat→write 窗口内可能截断刚到的上传内容,同用户秒级交错才触发,低危、接受。
279
284
  await (deps.writeFile ?? fsp.writeFile)(resolved, '');
280
285
  return {};
281
286
  }
@@ -389,7 +394,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
389
394
 
390
395
  let workspaceDir, resolved;
391
396
  try {
392
- const agentId = req.agentId?.trim?.() || 'main'; /* c8 ignore next -- ?./?? fallback */
397
+ const agentId = normalizeAgentId(req);
393
398
  workspaceDir = await resolveWorkspace(agentId);
394
399
  resolved = await validatePath(workspaceDir, req.path, pathDeps);
395
400
  } catch (err) {
@@ -528,7 +533,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
528
533
  async function handlePut(dc, req, connId) {
529
534
  let workspaceDir, resolved;
530
535
  try {
531
- const agentId = req.agentId?.trim?.() || 'main';
536
+ const agentId = normalizeAgentId(req);
532
537
  workspaceDir = await resolveWorkspace(agentId);
533
538
  resolved = await validatePath(workspaceDir, req.path, pathDeps);
534
539
  } catch (err) {
@@ -541,7 +546,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
541
546
  async function handlePost(dc, req, connId) {
542
547
  let workspaceDir, dirResolved;
543
548
  try {
544
- const agentId = req.agentId?.trim?.() || 'main';
549
+ const agentId = normalizeAgentId(req);
545
550
  workspaceDir = await resolveWorkspace(agentId);
546
551
  dirResolved = await validatePath(workspaceDir, req.path || '.', pathDeps);
547
552
  } catch (err) {
@@ -774,6 +779,9 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
774
779
  if (pendingQueue.length === 0 && !draining) finishUpload();
775
780
  }
776
781
  } else {
782
+ // done 已收到后到达的迟到 binary 分片必须拒收:否则越界字节会被累加进
783
+ // receivedBytes 并落盘,让本应 size-mismatch 的传输蒙混过 size 校验、生成错误文件。
784
+ if (doneReceived) return;
777
785
  // binary 数据帧 — 入队,由 drainLoop 按节奏写入
778
786
  const chunk = event.data;
779
787
  const len = chunk.byteLength ?? chunk.length ?? 0;
@@ -959,6 +959,10 @@ export class RealtimeBridge {
959
959
  }
960
960
  }
961
961
  // 终态才清条目;accepted 类中间态保留等下一帧
962
+ // 故意不把 delete 移到 await sendTo 之后:上游对每个 reqId 只发一帧终态 res、不重发,
963
+ // await 期间不会有第二帧同 id;万一到来则 miss 路由走 (d) 兜底广播,而 claw 与 userId
964
+ // 一一绑定(server validateClawOwnership),只会达同一用户自有 tab、原发起 tab 已 resolve
965
+ // 会忽略——无跨用户泄漏、无副作用,故不防。
962
966
  if (isFinalResMsg(payload)) {
963
967
  this.__dcPendingRequests.delete(payload.id);
964
968
  this.logger.debug?.(`[coclaw/rpc-res-route] remove reqId=${payload.id} reason=final-res`);
@@ -1161,6 +1165,9 @@ export class RealtimeBridge {
1161
1165
  }
1162
1166
  // 撞号检测:UUID 全唯一时极小概率,但旧 UI 跨 tab 或 UI bug 可能触发。
1163
1167
  // 删旧条目让旧响应未来走广播兜底,不主动回错给旧发起方(可能已断)
1168
+ // 故意不再回 DUPLICATE_REQUEST_ID 通知旧发起方:reqId 带 crypto.randomUUID 每连接前缀,跨连接
1169
+ // 撞号密码学不可达;且 claw↔userId 一一绑定,所有 DC peer 同属一用户,撞号即便由未来 UI bug 引入
1170
+ // 也只在该用户自有 tab 间串响应(可重试恢复)、无跨用户泄漏,故不加守卫。
1164
1171
  const id = payload.id;
1165
1172
  if (typeof id === 'string' && this.__dcPendingRequests.has(id)) {
1166
1173
  this.logger.warn?.(`[coclaw] duplicate dc reqId, dropping previous mapping: id=${id}`);
@@ -1429,12 +1436,16 @@ export class RealtimeBridge {
1429
1436
  // 保留 webrtcPeer / fileHandler 实例供重连后复用,避免雪崩式 PC 重建。
1430
1437
  const isAuthClose = event?.code === 4001 || event?.code === 4003;
1431
1438
  if (isAuthClose) {
1439
+ // 先等在飞的 lazy init 落地:close 撞上 __initWebrtcPeer 在飞时 webrtcPeer 仍是 null,
1440
+ // 不等就会漏掉对那个即将诞生的 peer/fileHandler 的清理,留下无主 PC。
1441
+ await this.__webrtcPeerReady?.catch(() => {});
1442
+ // 无条件清空:覆盖 init 已完成但 peer 仍可能为 null 的所有分支(init 失败、无 impl 等)。
1443
+ this.__webrtcPeerReady = null;
1432
1444
  if (this.webrtcPeer) {
1433
1445
  try { await this.webrtcPeer.closeAll(); }
1434
1446
  /* c8 ignore next 3 -- 防御性兜底,werift close 异常时不可崩溃 gateway */
1435
1447
  catch (e) { this.logger.warn?.(`[coclaw/rtc] closeAll failed: ${e?.message}`); }
1436
1448
  this.webrtcPeer = null;
1437
- this.__webrtcPeerReady = null;
1438
1449
  }
1439
1450
  if (this.__fileHandler) {
1440
1451
  this.__fileHandler.cancelCleanup();
@@ -1680,10 +1691,13 @@ export class RealtimeBridge {
1680
1691
  // 但显式销毁时必须清干净,避免 refresh 后留下指向旧 connId 的孤儿条目。
1681
1692
  this.__dcPendingRequests.clear();
1682
1693
  this.__closeGatewayWs();
1694
+ // 镜像 auth-close 守卫:stop()(refresh/restart 会走)撞 __initWebrtcPeer 在飞时
1695
+ // webrtcPeer 仍 null,不等就漏清理那个即将诞生的 peer/fileHandler,refresh/restart 后留无主实例。
1696
+ await this.__webrtcPeerReady?.catch(() => {});
1697
+ this.__webrtcPeerReady = null;
1683
1698
  if (this.webrtcPeer) {
1684
1699
  await this.webrtcPeer.closeAll().catch(() => {});
1685
1700
  this.webrtcPeer = null;
1686
- this.__webrtcPeerReady = null;
1687
1701
  }
1688
1702
  // pion: 关闭 Go 进程(异步,快速)
1689
1703
  // ndc: 不调用 cleanup()——同步 join native threads 耗时 10s+,进程退出时 OS 回收
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 归一化 RPC / DC 请求里的 agentId。
3
+ *
4
+ * 缺省(undefined / null)回落到默认 agent 'main';空串 / 纯空白同样回落到 'main'(保持
5
+ * 既有 `X?.agentId?.trim?.() || 'main'` 契约)。但**非字符串**(number / object / bool /
6
+ * array 等)视为非法输入并响亮抛错——静默落 'main' 会让读站点跨 workspace 泄露文件列表/内容、
7
+ * 写站点误改默认 agent 的数据。
8
+ *
9
+ * 抛出的 Error 带 `.code = 'INVALID_INPUT'`,由各 handler 的外层 catch(index.js 的
10
+ * respondError / file-manager 的 sendError)浮现为结构化错误码。
11
+ *
12
+ * @param {{ agentId?: unknown }} [params] - 请求参数容器(index.js 的 params / handler.js 的 req)
13
+ * @returns {string} 归一化后的 agentId
14
+ */
15
+ export function normalizeAgentId(params) {
16
+ const raw = params?.agentId;
17
+ if (raw === undefined || raw === null) return 'main';
18
+ if (typeof raw !== 'string') {
19
+ const err = new Error('agentId must be a string');
20
+ err.code = 'INVALID_INPUT';
21
+ throw err;
22
+ }
23
+ return raw.trim() || 'main'; // 空串 / 纯空白 → 'main'
24
+ }
@@ -213,8 +213,13 @@ export async function preloadNdc(deps = {}) {
213
213
  log(`ndc.loaded platform=${platformKey}`);
214
214
  return { PeerConnection: wrapNdcCredentials(RTCPeerConnection), cleanup, impl: 'ndc' };
215
215
  } catch (err) {
216
- // resolvePaths 或其他未预期异常的兜底
217
- log(`ndc.fallback reason=unexpected error=${err.message}`);
216
+ // node-datachannel 依赖已摘除:require.resolve 抛 MODULE_NOT_FOUND 是过渡期
217
+ // 预期路径,记为 skip 而非误导性的 unexpected。其余才是真正未预期异常。
218
+ if (err?.code === 'MODULE_NOT_FOUND') {
219
+ log('ndc.skip reason=ndc-not-installed');
220
+ } else {
221
+ log(`ndc.fallback reason=unexpected error=${err.message}`);
222
+ }
218
223
  return weriftFallback(dynamicImport, log, importTimeout);
219
224
  }
220
225
  }
@@ -208,13 +208,17 @@ export class WebRtcPeer {
208
208
  if (!expectedSession) return;
209
209
  if (this.__sessions.get(connId) !== expectedSession) return;
210
210
  const session = expectedSession;
211
- // 同步先 detach handler + clear timer,再 delete map,最后才进入 fire-and-forget 兼容的
212
- // 异步收尾(sender/queue/pc.close)。这个顺序保证:(1) pc.close 期间的滞后回调不会
213
- // 误改新 session;(2) sessions.get(connId) 在删表后立即返回 undefined,并发 offer 走
214
- // 同步段五件事路径而非误以为"close 还在跑"。
211
+ // 同步段顺序:先 delete map,再 detach handler + clear timer,最后进入 fire-and-forget
212
+ // 兼容的异步收尾(sender/queue/pc.close)。三步同步、彼此间无 await,外部异步观察者
213
+ // (并发 offer / pc.close 滞后回调)看到的是整段原子切换,故 delete 提前不改变原有两条
214
+ // 保证:(1) pc.close 期间的滞后回调不会误改新 session(detach 仍在 pc.close 之前);
215
+ // (2) sessions.get(connId) 删表后立即返回 undefined,并发 offer 走替换路径而非误以为
216
+ // "close 还在跑"。delete 必须排在任何可能抛错的同步步骤之前:closeAll drain 用
217
+ // per-session catch 吞掉单个 reject,若 detach/clear 在 delete 前抛错,session 会残留在
218
+ // 表里让 while-drain 死循环、拖挂 gateway——delete 先行使这条不变量无条件成立。
219
+ this.__sessions.delete(connId);
215
220
  this.__detachPcHandlers(session);
216
221
  this.__clearSessionSyncState(session);
217
- this.__sessions.delete(connId);
218
222
  await this.__finalizeSessionAsync(session);
219
223
  this.__remoteLog(`rtc.closed conn=${connId}`);
220
224
  this.logger.info?.(`${this.__rtcTag} [${connId}] closed`);
@@ -236,7 +240,15 @@ export class WebRtcPeer {
236
240
  // 下次复用会建新实例从 false 起。
237
241
  this.__stopping = true;
238
242
  while (this.__sessions.size > 0) {
239
- const closing = [...this.__sessions.values()].map((s) => this.closeByConnId(s.connId, s));
243
+ // per-session catch:单个 closeByConnId reject 不能中断其余 session 的清理,也不能
244
+ // 打断 while-drain 兜底循环。session 在 closeByConnId 首个 await 前已同步从 Map 删除,
245
+ // 故失败的 session 不会被下一轮重复处理、也不会无限循环。失败降级为 warn 诊断而非
246
+ // 静默全吞,保留定位线索。
247
+ const closing = [...this.__sessions.values()].map((s) =>
248
+ this.closeByConnId(s.connId, s).catch((err) => {
249
+ this.logger.warn?.(`${this.__rtcTag} [${s.connId}] closeByConnId failed during closeAll: ${err?.message ?? err}`);
250
+ }),
251
+ );
240
252
  await Promise.all(closing);
241
253
  }
242
254
  }
@@ -1028,6 +1040,10 @@ export class WebRtcPeer {
1028
1040
  // FBQ.init 承担 fs 残留清理(含同 connId 唯一后缀文件,不会撞旧实例);MemoryQueue.init 是 no-op。
1029
1041
  // await 期间可能发生 closeByConnId / 同 connId 二次 ondatachannel,因此后面必须身份重核才能赋字段。
1030
1042
  await queue.init();
1043
+ // 故意不为同 connId 并发 setup 引入 rpcSetupInProgress 协调锁:双 loop / 泄漏被下方身份重核
1044
+ // + 段首 teardown 结构性挡死(rpcChannel 在 ondatachannel 同步路径单调前移:先到者被后到
1045
+ // teardown 收割,后到者 dc 已非 rpcChannel 则自毁返回);即便分析有误,孤儿也只是空转 idle
1046
+ // 任务 + 少量内存(无 producer → 无 spill → 无 FD),无崩溃 / 丢数据。
1031
1047
  // 身份重核:init 期间 session 可能被 closeByConnId 从 Map 删除,或被同 connId 二次
1032
1048
  // ondatachannel 把 rpcChannel 替换成新 dc。任一不再成立都视为 stale,destroy queue 后
1033
1049
  // 直接退出,绝不污染 session 三件套字段。monitor 自然 GC,无需 summarize(无 drop)。