@seamnet/client 0.13.0 → 0.13.1

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.
@@ -136,17 +136,35 @@ function createImPlugin() {
136
136
 
137
137
  // ===== Supervisor 状态(SDK 僵尸时自动重建 chat 实例)=====
138
138
  let credentialsRef = null;
139
- let rebuildInProgress = false;
140
- let lastRebuildAt = 0;
139
+ // 状态机:scheduled(待启动)/ rebuilding(进行中);期间又来信号标记 requestedAgain
140
+ let rebuildScheduled = false;
141
+ let rebuilding = false;
142
+ let rebuildRequestedAgain = false;
143
+ let lastRebuildSuccessAt = 0;
144
+ let chatGeneration = 0; // chat 实例代数,每次 create 递增;listener 校验代数,老代事件忽略
141
145
  let keepaliveTimer = null;
142
146
  let processRejectionHandler = null;
143
147
  const REBUILD_COOLDOWN_MS = 10_000;
144
148
  const REBUILD_DEBOUNCE_MS = 3_000;
149
+ const REBUILD_WATCHDOG_MS = 30_000; // 整个重建最多 30s,超时强制失败
150
+ const SDK_CALL_TIMEOUT_MS = 10_000; // 单个 SDK 调用超时
151
+ const LOGOUT_TIMEOUT_MS = 5_000;
152
+ const LOGIN_TIMEOUT_MS = 15_000;
145
153
  const KEEPALIVE_INTERVAL_MS = 3 * 60 * 1000;
146
154
  const KEEPALIVE_TIMEOUT_MS = 10_000;
147
155
  // SDK 错误 code 需要触发 rebuild(2801=PING 超时,WebSocket 应用层僵尸)
148
156
  const SDK_REBUILD_ERROR_CODES = new Set([2801]);
149
157
 
158
+ function withTimeout(promise, ms, name) {
159
+ let tid = null;
160
+ const timeoutP = new Promise((_, reject) => {
161
+ tid = setTimeout(() => reject(new Error(`${name} timeout ${ms}ms`)), ms);
162
+ });
163
+ return Promise.race([promise, timeoutP]).finally(() => {
164
+ if (tid) clearTimeout(tid);
165
+ });
166
+ }
167
+
150
168
  async function prefetchDisplayName(userId) {
151
169
  if (!chat || !userId || displayNameCache.has(userId)) return;
152
170
  displayNameCache.set(userId, null);
@@ -339,50 +357,62 @@ function createImPlugin() {
339
357
  return { ok: true };
340
358
  }
341
359
 
342
- // ===== 事件 handler(独立函数,便于 rebuild 时重新注册)=====
343
-
344
- function onSdkReady() {
345
- sdkReady = true;
346
- log.info('sdk_ready', { userId: myUserId });
347
- }
348
-
349
- function onSdkNotReady() {
350
- sdkReady = false;
351
- log.warn('sdk_not_ready');
352
- }
360
+ // ===== 事件 handler 工厂(每次 setupAllListeners 时绑定当前 generation)=====
361
+ // handler 校验 generation,老实例晚到的事件直接忽略,避免污染新状态
353
362
 
354
- function onSdkError(event) {
355
- const err = event?.data || event;
356
- const code = err?.code;
357
- log.warn('sdk_error', {
358
- code,
359
- message: err?.message || String(err).slice(0, 200),
360
- });
361
- if (SDK_REBUILD_ERROR_CODES.has(code)) {
362
- scheduleRebuild(`sdk_error:${code}`);
363
- }
364
- }
365
-
366
- function onKickedOut(event) {
367
- log.warn('sdk_kicked_out', {
368
- type: event?.data?.type,
369
- message: event?.data?.message,
370
- });
371
- sdkReady = false;
372
- scheduleRebuild('kicked_out');
373
- }
374
-
375
- function onNetStateChange(event) {
376
- log.info('sdk_net_state', { state: event?.data?.state });
377
- }
378
-
379
- function onFriendListUpdated() {
380
- refreshContactsFromFriendList().catch((err) => {
381
- log.warn('refresh_contacts_on_update_failed', { message: err.message });
382
- });
363
+ function makeHandlers(gen) {
364
+ return {
365
+ onSdkReady: () => {
366
+ if (gen !== chatGeneration) return;
367
+ sdkReady = true;
368
+ log.info('sdk_ready', { userId: myUserId, gen });
369
+ },
370
+ onSdkNotReady: () => {
371
+ if (gen !== chatGeneration) return;
372
+ sdkReady = false;
373
+ log.warn('sdk_not_ready', { gen });
374
+ },
375
+ onSdkError: (event) => {
376
+ if (gen !== chatGeneration) return;
377
+ const err = event?.data || event;
378
+ const code = err?.code;
379
+ log.warn('sdk_error', {
380
+ code,
381
+ message: err?.message || String(err).slice(0, 200),
382
+ gen,
383
+ });
384
+ if (SDK_REBUILD_ERROR_CODES.has(code)) {
385
+ scheduleRebuild(`sdk_error:${code}`);
386
+ }
387
+ },
388
+ onKickedOut: (event) => {
389
+ if (gen !== chatGeneration) return;
390
+ log.warn('sdk_kicked_out', {
391
+ type: event?.data?.type,
392
+ message: event?.data?.message,
393
+ gen,
394
+ });
395
+ sdkReady = false;
396
+ scheduleRebuild('kicked_out');
397
+ },
398
+ onNetStateChange: (event) => {
399
+ if (gen !== chatGeneration) return;
400
+ log.info('sdk_net_state', { state: event?.data?.state, gen });
401
+ },
402
+ onFriendListUpdated: () => {
403
+ if (gen !== chatGeneration) return;
404
+ refreshContactsFromFriendList().catch((err) => {
405
+ log.warn('refresh_contacts_on_update_failed', { message: err.message });
406
+ });
407
+ },
408
+ onMessageReceived: (event) => {
409
+ if (gen !== chatGeneration) return;
410
+ handleMessageEvent(event);
411
+ },
412
+ };
383
413
  }
384
414
 
385
- function onMessageReceived(event) {
415
+ function handleMessageEvent(event) {
386
416
  for (const msg of event.data) {
387
417
  if (msg.from === myUserId) continue;
388
418
 
@@ -500,14 +530,15 @@ function createImPlugin() {
500
530
  }
501
531
 
502
532
  function setupAllListeners() {
503
- chat.on(TencentCloudChat.EVENT.SDK_READY, onSdkReady);
504
- chat.on(TencentCloudChat.EVENT.SDK_NOT_READY, onSdkNotReady);
505
- chat.on(TencentCloudChat.EVENT.ERROR, onSdkError);
506
- chat.on(TencentCloudChat.EVENT.KICKED_OUT, onKickedOut);
507
- chat.on(TencentCloudChat.EVENT.NET_STATE_CHANGE, onNetStateChange);
508
- chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived);
533
+ const h = makeHandlers(chatGeneration);
534
+ chat.on(TencentCloudChat.EVENT.SDK_READY, h.onSdkReady);
535
+ chat.on(TencentCloudChat.EVENT.SDK_NOT_READY, h.onSdkNotReady);
536
+ chat.on(TencentCloudChat.EVENT.ERROR, h.onSdkError);
537
+ chat.on(TencentCloudChat.EVENT.KICKED_OUT, h.onKickedOut);
538
+ chat.on(TencentCloudChat.EVENT.NET_STATE_CHANGE, h.onNetStateChange);
539
+ chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, h.onMessageReceived);
509
540
  try {
510
- chat.on(TencentCloudChat.EVENT.FRIEND_LIST_UPDATED, onFriendListUpdated);
541
+ chat.on(TencentCloudChat.EVENT.FRIEND_LIST_UPDATED, h.onFriendListUpdated);
511
542
  } catch (err) {
512
543
  log.warn('friend_list_listener_register_failed', { message: err.message });
513
544
  }
@@ -516,7 +547,11 @@ function createImPlugin() {
516
547
  // ===== 重建 chat 实例(SDK 僵尸时用)=====
517
548
 
518
549
  async function createAndLoginChat() {
550
+ // 每次 create 递增 generation,让老实例的 handler 自动失效
551
+ chatGeneration += 1;
552
+ // 清理 globals 让 setupSdkGlobals 能重新设置 window 等
519
553
  delete global.window;
554
+ global._imSdkGlobalsSet = false;
520
555
  chat = TencentCloudChat.create({ SDKAppID: Number(credentialsRef.sdkAppId) });
521
556
  setupSdkGlobals();
522
557
  try {
@@ -525,11 +560,12 @@ function createImPlugin() {
525
560
  } catch {}
526
561
  chat.setLogLevel(4);
527
562
  setupAllListeners();
528
- await chat.login({
529
- userID: myUserId,
530
- userSig: credentialsRef.userSig,
531
- });
532
- // 等 SDK_READY(最多 10s)
563
+ await withTimeout(
564
+ chat.login({ userID: myUserId, userSig: credentialsRef.userSig }),
565
+ LOGIN_TIMEOUT_MS,
566
+ 'chat.login'
567
+ );
568
+ // 等 SDK_READY(最多 10s)——sdkReady 可能已被 handler 置 true
533
569
  await new Promise((resolve) => {
534
570
  if (sdkReady) return resolve();
535
571
  const timer = setTimeout(() => resolve(), 10000);
@@ -539,48 +575,71 @@ function createImPlugin() {
539
575
  }
540
576
 
541
577
  async function rebuildChat(reason) {
542
- log.warn('chat_rebuild_start', { reason });
578
+ log.warn('chat_rebuild_start', { reason, prevGen: chatGeneration });
543
579
  sdkReady = false;
544
- try { await chat.logout(); } catch {}
545
- try { chat.destroy && chat.destroy(); } catch {}
580
+ // 每步都 timeout:老 chat 僵尸可能让 logout 永不 resolve
581
+ const oldChat = chat;
582
+ try {
583
+ await withTimeout(oldChat.logout().catch(() => {}), LOGOUT_TIMEOUT_MS, 'logout');
584
+ } catch (e) {
585
+ log.warn('logout_timeout', { message: e.message });
586
+ }
587
+ try { oldChat.destroy && oldChat.destroy(); } catch {}
588
+ // createAndLoginChat 自身会递增 chatGeneration,老 listener 自动失效
589
+ await createAndLoginChat();
546
590
  try {
547
- await createAndLoginChat();
548
- try { await refreshContactsFromFriendList(); } catch {}
549
- log.info('chat_rebuild_done', { reason });
591
+ await withTimeout(refreshContactsFromFriendList(), SDK_CALL_TIMEOUT_MS, 'refresh_contacts');
550
592
  } catch (e) {
551
- log.error('chat_rebuild_failed', e, { reason });
593
+ log.warn('refresh_contacts_after_rebuild_timeout', { message: e.message });
594
+ }
595
+ log.info('chat_rebuild_done', { reason, newGen: chatGeneration });
596
+ }
597
+
598
+ async function doRebuild(reason) {
599
+ rebuildScheduled = false;
600
+ rebuilding = true;
601
+ try {
602
+ await withTimeout(rebuildChat(reason), REBUILD_WATCHDOG_MS, 'rebuild_watchdog');
603
+ lastRebuildSuccessAt = Date.now();
604
+ } catch (e) {
605
+ log.error('chat_rebuild_failed', e, { reason, message: e.message });
606
+ // 注意:lastRebuildSuccessAt 不更新,允许立即重试(不被 cooldown 拦)
607
+ } finally {
608
+ rebuilding = false;
609
+ if (rebuildRequestedAgain) {
610
+ rebuildRequestedAgain = false;
611
+ scheduleRebuild('trailing');
612
+ }
552
613
  }
553
614
  }
554
615
 
555
616
  function scheduleRebuild(reason) {
556
- if (rebuildInProgress) return;
617
+ // 已在重建中:标记 trailing,结束后再跑一轮
618
+ if (rebuilding) {
619
+ rebuildRequestedAgain = true;
620
+ log.info('chat_rebuild_queued_trailing', { reason });
621
+ return;
622
+ }
623
+ // 已在等待启动:合并(debounce)
624
+ if (rebuildScheduled) return;
625
+ // Cooldown 只针对上次"成功"的重建——失败后允许立即重试
557
626
  const now = Date.now();
558
- if (now - lastRebuildAt < REBUILD_COOLDOWN_MS) {
627
+ if (now - lastRebuildSuccessAt < REBUILD_COOLDOWN_MS) {
559
628
  log.info('chat_rebuild_skipped_cooldown', { reason });
560
629
  return;
561
630
  }
562
- rebuildInProgress = true;
563
- setTimeout(async () => {
564
- try {
565
- await rebuildChat(reason);
566
- lastRebuildAt = Date.now();
567
- } finally {
568
- rebuildInProgress = false;
569
- }
570
- }, REBUILD_DEBOUNCE_MS);
631
+ // 立即降级:阻止发送路径继续用坏 chat
632
+ sdkReady = false;
633
+ rebuildScheduled = true;
634
+ setTimeout(() => { doRebuild(reason).catch(() => {}); }, REBUILD_DEBOUNCE_MS);
571
635
  }
572
636
 
573
637
  function startKeepalive() {
574
638
  if (keepaliveTimer) return;
575
639
  keepaliveTimer = setInterval(async () => {
576
- if (!sdkReady || rebuildInProgress) return;
640
+ if (!sdkReady || rebuilding || rebuildScheduled) return;
577
641
  try {
578
- await Promise.race([
579
- chat.getMyProfile(),
580
- new Promise((_, reject) =>
581
- setTimeout(() => reject(new Error('keepalive timeout')), KEEPALIVE_TIMEOUT_MS)
582
- ),
583
- ]);
642
+ await withTimeout(chat.getMyProfile(), KEEPALIVE_TIMEOUT_MS, 'keepalive');
584
643
  } catch (e) {
585
644
  log.warn('keepalive_failed', { message: e.message });
586
645
  scheduleRebuild(`keepalive:${e.message}`);
@@ -612,6 +671,7 @@ function createImPlugin() {
612
671
  // 先 delete window 让 SDK 走 node 路径
613
672
  delete global.window;
614
673
  TencentCloudChat = require('@tencentcloud/chat');
674
+ chatGeneration += 1; // 首次也是 gen 1(listener 校验需要)
615
675
  chat = TencentCloudChat.create({ SDKAppID: Number(credentials.sdkAppId) });
616
676
 
617
677
  // 然后补上 SDK 运行所需的 window/document 等全局
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seamnet/client",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "One command to join Seam — the network where people and AI stay in sync.",
5
5
  "bin": {
6
6
  "seam-client": "bin/cli.js",