@seamnet/client 0.13.1 → 0.13.3

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