@seamnet/client 0.13.1 → 0.13.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.
@@ -144,14 +144,20 @@ function createImPlugin() {
144
144
  let chatGeneration = 0; // chat 实例代数,每次 create 递增;listener 校验代数,老代事件忽略
145
145
  let keepaliveTimer = null;
146
146
  let processRejectionHandler = null;
147
+ // 重建失败后自驱重试:指数退避 + jitter;成功后重置
148
+ let rebuildRetryAttempt = 0;
149
+ let rebuildRetryTimer = null;
147
150
  const REBUILD_COOLDOWN_MS = 10_000;
148
151
  const REBUILD_DEBOUNCE_MS = 3_000;
149
152
  const REBUILD_WATCHDOG_MS = 30_000; // 整个重建最多 30s,超时强制失败
150
153
  const SDK_CALL_TIMEOUT_MS = 10_000; // 单个 SDK 调用超时
151
154
  const LOGOUT_TIMEOUT_MS = 5_000;
152
155
  const LOGIN_TIMEOUT_MS = 15_000;
156
+ const SDK_READY_TIMEOUT_MS = 10_000; // 等 SDK_READY 最长 10s,超时 reject
153
157
  const KEEPALIVE_INTERVAL_MS = 3 * 60 * 1000;
154
158
  const KEEPALIVE_TIMEOUT_MS = 10_000;
159
+ // 重建失败退避表(最后一档持续)
160
+ const REBUILD_RETRY_BACKOFF_MS = [3_000, 10_000, 30_000, 60_000];
155
161
  // SDK 错误 code 需要触发 rebuild(2801=PING 超时,WebSocket 应用层僵尸)
156
162
  const SDK_REBUILD_ERROR_CODES = new Set([2801]);
157
163
 
@@ -357,14 +363,14 @@ function createImPlugin() {
357
363
  return { ok: true };
358
364
  }
359
365
 
360
- // ===== 事件 handler 工厂(每次 setupAllListeners 时绑定当前 generation)=====
366
+ // ===== 事件 handler 工厂(每次 attachListenersTo 时绑定当前 generation)=====
361
367
  // handler 校验 generation,老实例晚到的事件直接忽略,避免污染新状态
362
368
 
363
369
  function makeHandlers(gen) {
364
370
  return {
365
371
  onSdkReady: () => {
366
372
  if (gen !== chatGeneration) return;
367
- sdkReady = true;
373
+ // sdkReady 的置 true 统一在 rebuildChat/init 成功 swap 后做,handler 只记录
368
374
  log.info('sdk_ready', { userId: myUserId, gen });
369
375
  },
370
376
  onSdkNotReady: () => {
@@ -529,70 +535,110 @@ function createImPlugin() {
529
535
  }
530
536
  }
531
537
 
532
- function setupAllListeners() {
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);
538
+ function attachListenersTo(chatInstance, gen) {
539
+ const h = makeHandlers(gen);
540
+ chatInstance.on(TencentCloudChat.EVENT.SDK_READY, h.onSdkReady);
541
+ chatInstance.on(TencentCloudChat.EVENT.SDK_NOT_READY, h.onSdkNotReady);
542
+ chatInstance.on(TencentCloudChat.EVENT.ERROR, h.onSdkError);
543
+ chatInstance.on(TencentCloudChat.EVENT.KICKED_OUT, h.onKickedOut);
544
+ chatInstance.on(TencentCloudChat.EVENT.NET_STATE_CHANGE, h.onNetStateChange);
545
+ chatInstance.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, h.onMessageReceived);
540
546
  try {
541
- chat.on(TencentCloudChat.EVENT.FRIEND_LIST_UPDATED, h.onFriendListUpdated);
547
+ chatInstance.on(TencentCloudChat.EVENT.FRIEND_LIST_UPDATED, h.onFriendListUpdated);
542
548
  } catch (err) {
543
549
  log.warn('friend_list_listener_register_failed', { message: err.message });
544
550
  }
545
551
  }
546
552
 
547
- // ===== 重建 chat 实例(SDK 僵尸时用)=====
553
+ function waitForSdkReadyStrict(chatInstance, ms) {
554
+ return new Promise((resolve, reject) => {
555
+ const timer = setTimeout(
556
+ () => reject(new Error(`SDK_READY timeout ${ms}ms`)),
557
+ ms
558
+ );
559
+ chatInstance.on(TencentCloudChat.EVENT.SDK_READY, () => {
560
+ clearTimeout(timer);
561
+ resolve();
562
+ });
563
+ });
564
+ }
548
565
 
549
- async function createAndLoginChat() {
550
- // 每次 create 递增 generation,让老实例的 handler 自动失效
551
- chatGeneration += 1;
552
- // 清理 globals setupSdkGlobals 能重新设置 window 等
566
+ // ===== 候选模式构建新 chat(失败则隔离销毁,不污染 active chat)=====
567
+ async function buildChatCandidate() {
568
+ const myGen = ++chatGeneration;
569
+ // globals,让 setupSdkGlobals 重新铺 window 等
553
570
  delete global.window;
554
571
  global._imSdkGlobalsSet = false;
555
- chat = TencentCloudChat.create({ SDKAppID: Number(credentialsRef.sdkAppId) });
572
+ const nextChat = TencentCloudChat.create({ SDKAppID: Number(credentialsRef.sdkAppId) });
556
573
  setupSdkGlobals();
557
574
  try {
558
575
  const TIMUploadPlugin = require('tim-upload-plugin');
559
- chat.registerPlugin({ 'tim-upload-plugin': TIMUploadPlugin });
576
+ nextChat.registerPlugin({ 'tim-upload-plugin': TIMUploadPlugin });
560
577
  } catch {}
561
- chat.setLogLevel(4);
562
- setupAllListeners();
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
569
- await new Promise((resolve) => {
570
- if (sdkReady) return resolve();
571
- const timer = setTimeout(() => resolve(), 10000);
572
- const once = () => { clearTimeout(timer); resolve(); };
573
- chat.on(TencentCloudChat.EVENT.SDK_READY, once);
574
- });
578
+ nextChat.setLogLevel(4);
579
+ attachListenersTo(nextChat, myGen);
580
+ try {
581
+ await withTimeout(
582
+ nextChat.login({ userID: myUserId, userSig: credentialsRef.userSig }),
583
+ LOGIN_TIMEOUT_MS,
584
+ 'chat.login'
585
+ );
586
+ // 严格等 SDK_READY:超时 reject,不再假成功
587
+ await waitForSdkReadyStrict(nextChat, SDK_READY_TIMEOUT_MS);
588
+ } catch (err) {
589
+ // 失败的 candidate 必须隔离销毁,避免 late event 污染新状态
590
+ try {
591
+ await withTimeout(
592
+ (nextChat.logout && nextChat.logout().catch(() => {})) || Promise.resolve(),
593
+ LOGOUT_TIMEOUT_MS,
594
+ 'cand_logout'
595
+ );
596
+ } catch {}
597
+ try { nextChat.destroy && nextChat.destroy(); } catch {}
598
+ throw err;
599
+ }
600
+ return { nextChat, gen: myGen };
575
601
  }
576
602
 
603
+ // ===== 重建 chat 实例(SDK 僵尸时用)=====
577
604
  async function rebuildChat(reason) {
578
605
  log.warn('chat_rebuild_start', { reason, prevGen: chatGeneration });
579
606
  sdkReady = false;
580
- // 每步都 timeout:老 chat 僵尸可能让 logout 永不 resolve
581
607
  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();
608
+
609
+ // 候选模式:先把新 chat 完整拉起来(login + SDK_READY),失败则 throw,老 chat 保持不变
610
+ const { nextChat, gen: newGen } = await buildChatCandidate();
611
+
612
+ // 成功:swap 到新 chat;sdkReady 只在 swap 完成后置 true
613
+ chat = nextChat;
614
+ sdkReady = true;
615
+ log.info('chat_rebuild_done', { reason, newGen });
616
+
617
+ // 老 chat 后台销毁,不阻塞新 chat 服务
618
+ (async () => {
619
+ try {
620
+ if (oldChat && oldChat.logout) {
621
+ await withTimeout(oldChat.logout().catch(() => {}), LOGOUT_TIMEOUT_MS, 'old_logout');
622
+ }
623
+ } catch (e) {
624
+ log.warn('old_logout_timeout', { message: e.message });
625
+ }
626
+ try { oldChat && oldChat.destroy && oldChat.destroy(); } catch {}
627
+ })();
628
+
590
629
  try {
591
630
  await withTimeout(refreshContactsFromFriendList(), SDK_CALL_TIMEOUT_MS, 'refresh_contacts');
592
631
  } catch (e) {
593
632
  log.warn('refresh_contacts_after_rebuild_timeout', { message: e.message });
594
633
  }
595
- log.info('chat_rebuild_done', { reason, newGen: chatGeneration });
634
+ }
635
+
636
+ function scheduleRebuildRetry(reason, delayMs) {
637
+ if (rebuildRetryTimer) clearTimeout(rebuildRetryTimer);
638
+ rebuildRetryTimer = setTimeout(() => {
639
+ rebuildRetryTimer = null;
640
+ scheduleRebuild(reason);
641
+ }, delayMs);
596
642
  }
597
643
 
598
644
  async function doRebuild(reason) {
@@ -601,9 +647,18 @@ function createImPlugin() {
601
647
  try {
602
648
  await withTimeout(rebuildChat(reason), REBUILD_WATCHDOG_MS, 'rebuild_watchdog');
603
649
  lastRebuildSuccessAt = Date.now();
650
+ rebuildRetryAttempt = 0;
651
+ if (rebuildRetryTimer) { clearTimeout(rebuildRetryTimer); rebuildRetryTimer = null; }
604
652
  } catch (e) {
605
- log.error('chat_rebuild_failed', e, { reason, message: e.message });
606
- // 注意:lastRebuildSuccessAt 不更新,允许立即重试(不被 cooldown 拦)
653
+ log.error('chat_rebuild_failed', e, { reason, message: e.message, attempt: rebuildRetryAttempt });
654
+ // 自驱重试:指数退避 + jitter;不依赖外部信号
655
+ const idx = Math.min(rebuildRetryAttempt, REBUILD_RETRY_BACKOFF_MS.length - 1);
656
+ const base = REBUILD_RETRY_BACKOFF_MS[idx];
657
+ const jitter = Math.floor(Math.random() * 1000);
658
+ const delay = base + jitter;
659
+ rebuildRetryAttempt += 1;
660
+ log.info('chat_rebuild_retry_scheduled', { attempt: rebuildRetryAttempt, delayMs: delay });
661
+ scheduleRebuildRetry(`auto_retry:${reason}`, delay);
607
662
  } finally {
608
663
  rebuilding = false;
609
664
  if (rebuildRequestedAgain) {
@@ -668,55 +723,26 @@ function createImPlugin() {
668
723
  myUserId = credentials.userId;
669
724
  credentialsRef = credentials;
670
725
 
671
- // 先 delete window 让 SDK 走 node 路径
672
- delete global.window;
673
726
  TencentCloudChat = require('@tencentcloud/chat');
674
- chatGeneration += 1; // 首次也是 gen 1(listener 校验需要)
675
- chat = TencentCloudChat.create({ SDKAppID: Number(credentials.sdkAppId) });
676
-
677
- // 然后补上 SDK 运行所需的 window/document 等全局
678
- setupSdkGlobals();
679
727
 
728
+ // 首次走 candidate 模式:严格 login + SDK_READY,失败直接 throw
680
729
  try {
681
- const TIMUploadPlugin = require('tim-upload-plugin');
682
- chat.registerPlugin({ 'tim-upload-plugin': TIMUploadPlugin });
730
+ const built = await buildChatCandidate();
731
+ chat = built.nextChat;
732
+ sdkReady = true;
733
+ log.info('login_ok', { userId: myUserId, gen: built.gen });
683
734
  log.info('upload_plugin_registered');
684
- } catch (e) {
685
- log.warn('upload_plugin_unavailable', { message: e.message });
686
- }
687
-
688
- chat.setLogLevel(4);
689
-
690
- // 所有事件监听通过 setupAllListeners 统一注册(便于 rebuild 时重注册)
691
- setupAllListeners();
692
-
693
- try {
694
- await chat.login({ userID: myUserId, userSig: credentials.userSig });
695
- log.info('login_ok', { userId: myUserId });
696
735
  } catch (err) {
697
736
  log.error('login_failed', err);
698
737
  throw wrap(err, {
699
738
  code: 'IM_LOGIN_FAILED',
700
- hint: '检查 userSig 是否过期、sdkAppId 是否正确',
739
+ hint: '检查 userSig 是否过期、sdkAppId 是否正确、网络到 my-imcloud.com',
701
740
  docs: 'docs/maintainer-guide.md#IM_LOGIN_FAILED',
702
741
  });
703
742
  }
704
743
 
705
- // 等 SDK_READY(最多 10s)
706
- await new Promise((resolve) => {
707
- if (sdkReady) return resolve();
708
- const timer = setTimeout(() => {
709
- log.warn('sdk_ready_wait_timeout');
710
- resolve();
711
- }, 10000);
712
- chat.on(TencentCloudChat.EVENT.SDK_READY, () => {
713
- clearTimeout(timer);
714
- resolve();
715
- });
716
- });
717
-
718
744
  // 拉一次好友列表建立关系表 contacts.json
719
- // FRIEND_LIST_UPDATED 监听已在 setupAllListeners 里注册
745
+ // FRIEND_LIST_UPDATED 监听已在 buildChatCandidate 里注册
720
746
  seedDisplayNameFromContacts();
721
747
  await refreshContactsFromFriendList();
722
748
 
@@ -790,17 +816,23 @@ function createImPlugin() {
790
816
  clearInterval(keepaliveTimer);
791
817
  keepaliveTimer = null;
792
818
  }
819
+ if (rebuildRetryTimer) {
820
+ clearTimeout(rebuildRetryTimer);
821
+ rebuildRetryTimer = null;
822
+ }
793
823
  if (processRejectionHandler) {
794
824
  try { process.off('unhandledRejection', processRejectionHandler); } catch {}
795
825
  processRejectionHandler = null;
796
826
  }
797
827
  if (chat) {
798
828
  try {
799
- await chat.logout();
829
+ await withTimeout(chat.logout().catch(() => {}), LOGOUT_TIMEOUT_MS, 'destroy_logout');
800
830
  if (log) log.info('logged_out');
801
831
  } catch (e) {
802
832
  if (log) log.warn('logout_failed', { message: e.message });
803
833
  }
834
+ try { chat.destroy && chat.destroy(); } catch {}
835
+ chat = null;
804
836
  }
805
837
  }
806
838
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seamnet/client",
3
- "version": "0.13.1",
3
+ "version": "0.13.2",
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",