@seamnet/client 0.13.0 → 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.
@@ -136,17 +136,41 @@ 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;
147
+ // 重建失败后自驱重试:指数退避 + jitter;成功后重置
148
+ let rebuildRetryAttempt = 0;
149
+ let rebuildRetryTimer = null;
143
150
  const REBUILD_COOLDOWN_MS = 10_000;
144
151
  const REBUILD_DEBOUNCE_MS = 3_000;
152
+ const REBUILD_WATCHDOG_MS = 30_000; // 整个重建最多 30s,超时强制失败
153
+ const SDK_CALL_TIMEOUT_MS = 10_000; // 单个 SDK 调用超时
154
+ const LOGOUT_TIMEOUT_MS = 5_000;
155
+ const LOGIN_TIMEOUT_MS = 15_000;
156
+ const SDK_READY_TIMEOUT_MS = 10_000; // 等 SDK_READY 最长 10s,超时 reject
145
157
  const KEEPALIVE_INTERVAL_MS = 3 * 60 * 1000;
146
158
  const KEEPALIVE_TIMEOUT_MS = 10_000;
159
+ // 重建失败退避表(最后一档持续)
160
+ const REBUILD_RETRY_BACKOFF_MS = [3_000, 10_000, 30_000, 60_000];
147
161
  // SDK 错误 code 需要触发 rebuild(2801=PING 超时,WebSocket 应用层僵尸)
148
162
  const SDK_REBUILD_ERROR_CODES = new Set([2801]);
149
163
 
164
+ function withTimeout(promise, ms, name) {
165
+ let tid = null;
166
+ const timeoutP = new Promise((_, reject) => {
167
+ tid = setTimeout(() => reject(new Error(`${name} timeout ${ms}ms`)), ms);
168
+ });
169
+ return Promise.race([promise, timeoutP]).finally(() => {
170
+ if (tid) clearTimeout(tid);
171
+ });
172
+ }
173
+
150
174
  async function prefetchDisplayName(userId) {
151
175
  if (!chat || !userId || displayNameCache.has(userId)) return;
152
176
  displayNameCache.set(userId, null);
@@ -339,50 +363,62 @@ function createImPlugin() {
339
363
  return { ok: true };
340
364
  }
341
365
 
342
- // ===== 事件 handler(独立函数,便于 rebuild 时重新注册)=====
366
+ // ===== 事件 handler 工厂(每次 attachListenersTo 时绑定当前 generation)=====
367
+ // handler 校验 generation,老实例晚到的事件直接忽略,避免污染新状态
343
368
 
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
- }
353
-
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
- });
369
+ function makeHandlers(gen) {
370
+ return {
371
+ onSdkReady: () => {
372
+ if (gen !== chatGeneration) return;
373
+ // sdkReady 的置 true 统一在 rebuildChat/init 成功 swap 后做,handler 只记录
374
+ log.info('sdk_ready', { userId: myUserId, gen });
375
+ },
376
+ onSdkNotReady: () => {
377
+ if (gen !== chatGeneration) return;
378
+ sdkReady = false;
379
+ log.warn('sdk_not_ready', { gen });
380
+ },
381
+ onSdkError: (event) => {
382
+ if (gen !== chatGeneration) return;
383
+ const err = event?.data || event;
384
+ const code = err?.code;
385
+ log.warn('sdk_error', {
386
+ code,
387
+ message: err?.message || String(err).slice(0, 200),
388
+ gen,
389
+ });
390
+ if (SDK_REBUILD_ERROR_CODES.has(code)) {
391
+ scheduleRebuild(`sdk_error:${code}`);
392
+ }
393
+ },
394
+ onKickedOut: (event) => {
395
+ if (gen !== chatGeneration) return;
396
+ log.warn('sdk_kicked_out', {
397
+ type: event?.data?.type,
398
+ message: event?.data?.message,
399
+ gen,
400
+ });
401
+ sdkReady = false;
402
+ scheduleRebuild('kicked_out');
403
+ },
404
+ onNetStateChange: (event) => {
405
+ if (gen !== chatGeneration) return;
406
+ log.info('sdk_net_state', { state: event?.data?.state, gen });
407
+ },
408
+ onFriendListUpdated: () => {
409
+ if (gen !== chatGeneration) return;
410
+ refreshContactsFromFriendList().catch((err) => {
411
+ log.warn('refresh_contacts_on_update_failed', { message: err.message });
412
+ });
413
+ },
414
+ onMessageReceived: (event) => {
415
+ if (gen !== chatGeneration) return;
416
+ handleMessageEvent(event);
417
+ },
418
+ };
383
419
  }
384
420
 
385
- function onMessageReceived(event) {
421
+ function handleMessageEvent(event) {
386
422
  for (const msg of event.data) {
387
423
  if (msg.from === myUserId) continue;
388
424
 
@@ -499,88 +535,166 @@ function createImPlugin() {
499
535
  }
500
536
  }
501
537
 
502
- 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);
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);
509
546
  try {
510
- chat.on(TencentCloudChat.EVENT.FRIEND_LIST_UPDATED, onFriendListUpdated);
547
+ chatInstance.on(TencentCloudChat.EVENT.FRIEND_LIST_UPDATED, h.onFriendListUpdated);
511
548
  } catch (err) {
512
549
  log.warn('friend_list_listener_register_failed', { message: err.message });
513
550
  }
514
551
  }
515
552
 
516
- // ===== 重建 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
+ }
517
565
 
518
- async function createAndLoginChat() {
566
+ // ===== 候选模式构建新 chat(失败则隔离销毁,不污染 active chat)=====
567
+ async function buildChatCandidate() {
568
+ const myGen = ++chatGeneration;
569
+ // 清 globals,让 setupSdkGlobals 重新铺 window 等
519
570
  delete global.window;
520
- chat = TencentCloudChat.create({ SDKAppID: Number(credentialsRef.sdkAppId) });
571
+ global._imSdkGlobalsSet = false;
572
+ const nextChat = TencentCloudChat.create({ SDKAppID: Number(credentialsRef.sdkAppId) });
521
573
  setupSdkGlobals();
522
574
  try {
523
575
  const TIMUploadPlugin = require('tim-upload-plugin');
524
- chat.registerPlugin({ 'tim-upload-plugin': TIMUploadPlugin });
576
+ nextChat.registerPlugin({ 'tim-upload-plugin': TIMUploadPlugin });
525
577
  } catch {}
526
- chat.setLogLevel(4);
527
- setupAllListeners();
528
- await chat.login({
529
- userID: myUserId,
530
- userSig: credentialsRef.userSig,
531
- });
532
- // 等 SDK_READY(最多 10s)
533
- await new Promise((resolve) => {
534
- if (sdkReady) return resolve();
535
- const timer = setTimeout(() => resolve(), 10000);
536
- const once = () => { clearTimeout(timer); resolve(); };
537
- chat.on(TencentCloudChat.EVENT.SDK_READY, once);
538
- });
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 };
539
601
  }
540
602
 
603
+ // ===== 重建 chat 实例(SDK 僵尸时用)=====
541
604
  async function rebuildChat(reason) {
542
- log.warn('chat_rebuild_start', { reason });
605
+ log.warn('chat_rebuild_start', { reason, prevGen: chatGeneration });
543
606
  sdkReady = false;
544
- try { await chat.logout(); } catch {}
545
- try { chat.destroy && chat.destroy(); } catch {}
607
+ const oldChat = chat;
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
+
546
629
  try {
547
- await createAndLoginChat();
548
- try { await refreshContactsFromFriendList(); } catch {}
549
- log.info('chat_rebuild_done', { reason });
630
+ await withTimeout(refreshContactsFromFriendList(), SDK_CALL_TIMEOUT_MS, 'refresh_contacts');
550
631
  } catch (e) {
551
- log.error('chat_rebuild_failed', e, { reason });
632
+ log.warn('refresh_contacts_after_rebuild_timeout', { message: e.message });
633
+ }
634
+ }
635
+
636
+ function scheduleRebuildRetry(reason, delayMs) {
637
+ if (rebuildRetryTimer) clearTimeout(rebuildRetryTimer);
638
+ rebuildRetryTimer = setTimeout(() => {
639
+ rebuildRetryTimer = null;
640
+ scheduleRebuild(reason);
641
+ }, delayMs);
642
+ }
643
+
644
+ async function doRebuild(reason) {
645
+ rebuildScheduled = false;
646
+ rebuilding = true;
647
+ try {
648
+ await withTimeout(rebuildChat(reason), REBUILD_WATCHDOG_MS, 'rebuild_watchdog');
649
+ lastRebuildSuccessAt = Date.now();
650
+ rebuildRetryAttempt = 0;
651
+ if (rebuildRetryTimer) { clearTimeout(rebuildRetryTimer); rebuildRetryTimer = null; }
652
+ } catch (e) {
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);
662
+ } finally {
663
+ rebuilding = false;
664
+ if (rebuildRequestedAgain) {
665
+ rebuildRequestedAgain = false;
666
+ scheduleRebuild('trailing');
667
+ }
552
668
  }
553
669
  }
554
670
 
555
671
  function scheduleRebuild(reason) {
556
- if (rebuildInProgress) return;
672
+ // 已在重建中:标记 trailing,结束后再跑一轮
673
+ if (rebuilding) {
674
+ rebuildRequestedAgain = true;
675
+ log.info('chat_rebuild_queued_trailing', { reason });
676
+ return;
677
+ }
678
+ // 已在等待启动:合并(debounce)
679
+ if (rebuildScheduled) return;
680
+ // Cooldown 只针对上次"成功"的重建——失败后允许立即重试
557
681
  const now = Date.now();
558
- if (now - lastRebuildAt < REBUILD_COOLDOWN_MS) {
682
+ if (now - lastRebuildSuccessAt < REBUILD_COOLDOWN_MS) {
559
683
  log.info('chat_rebuild_skipped_cooldown', { reason });
560
684
  return;
561
685
  }
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);
686
+ // 立即降级:阻止发送路径继续用坏 chat
687
+ sdkReady = false;
688
+ rebuildScheduled = true;
689
+ setTimeout(() => { doRebuild(reason).catch(() => {}); }, REBUILD_DEBOUNCE_MS);
571
690
  }
572
691
 
573
692
  function startKeepalive() {
574
693
  if (keepaliveTimer) return;
575
694
  keepaliveTimer = setInterval(async () => {
576
- if (!sdkReady || rebuildInProgress) return;
695
+ if (!sdkReady || rebuilding || rebuildScheduled) return;
577
696
  try {
578
- await Promise.race([
579
- chat.getMyProfile(),
580
- new Promise((_, reject) =>
581
- setTimeout(() => reject(new Error('keepalive timeout')), KEEPALIVE_TIMEOUT_MS)
582
- ),
583
- ]);
697
+ await withTimeout(chat.getMyProfile(), KEEPALIVE_TIMEOUT_MS, 'keepalive');
584
698
  } catch (e) {
585
699
  log.warn('keepalive_failed', { message: e.message });
586
700
  scheduleRebuild(`keepalive:${e.message}`);
@@ -609,54 +723,26 @@ function createImPlugin() {
609
723
  myUserId = credentials.userId;
610
724
  credentialsRef = credentials;
611
725
 
612
- // 先 delete window 让 SDK 走 node 路径
613
- delete global.window;
614
726
  TencentCloudChat = require('@tencentcloud/chat');
615
- chat = TencentCloudChat.create({ SDKAppID: Number(credentials.sdkAppId) });
616
-
617
- // 然后补上 SDK 运行所需的 window/document 等全局
618
- setupSdkGlobals();
619
727
 
728
+ // 首次走 candidate 模式:严格 login + SDK_READY,失败直接 throw
620
729
  try {
621
- const TIMUploadPlugin = require('tim-upload-plugin');
622
- 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 });
623
734
  log.info('upload_plugin_registered');
624
- } catch (e) {
625
- log.warn('upload_plugin_unavailable', { message: e.message });
626
- }
627
-
628
- chat.setLogLevel(4);
629
-
630
- // 所有事件监听通过 setupAllListeners 统一注册(便于 rebuild 时重注册)
631
- setupAllListeners();
632
-
633
- try {
634
- await chat.login({ userID: myUserId, userSig: credentials.userSig });
635
- log.info('login_ok', { userId: myUserId });
636
735
  } catch (err) {
637
736
  log.error('login_failed', err);
638
737
  throw wrap(err, {
639
738
  code: 'IM_LOGIN_FAILED',
640
- hint: '检查 userSig 是否过期、sdkAppId 是否正确',
739
+ hint: '检查 userSig 是否过期、sdkAppId 是否正确、网络到 my-imcloud.com',
641
740
  docs: 'docs/maintainer-guide.md#IM_LOGIN_FAILED',
642
741
  });
643
742
  }
644
743
 
645
- // 等 SDK_READY(最多 10s)
646
- await new Promise((resolve) => {
647
- if (sdkReady) return resolve();
648
- const timer = setTimeout(() => {
649
- log.warn('sdk_ready_wait_timeout');
650
- resolve();
651
- }, 10000);
652
- chat.on(TencentCloudChat.EVENT.SDK_READY, () => {
653
- clearTimeout(timer);
654
- resolve();
655
- });
656
- });
657
-
658
744
  // 拉一次好友列表建立关系表 contacts.json
659
- // FRIEND_LIST_UPDATED 监听已在 setupAllListeners 里注册
745
+ // FRIEND_LIST_UPDATED 监听已在 buildChatCandidate 里注册
660
746
  seedDisplayNameFromContacts();
661
747
  await refreshContactsFromFriendList();
662
748
 
@@ -730,17 +816,23 @@ function createImPlugin() {
730
816
  clearInterval(keepaliveTimer);
731
817
  keepaliveTimer = null;
732
818
  }
819
+ if (rebuildRetryTimer) {
820
+ clearTimeout(rebuildRetryTimer);
821
+ rebuildRetryTimer = null;
822
+ }
733
823
  if (processRejectionHandler) {
734
824
  try { process.off('unhandledRejection', processRejectionHandler); } catch {}
735
825
  processRejectionHandler = null;
736
826
  }
737
827
  if (chat) {
738
828
  try {
739
- await chat.logout();
829
+ await withTimeout(chat.logout().catch(() => {}), LOGOUT_TIMEOUT_MS, 'destroy_logout');
740
830
  if (log) log.info('logged_out');
741
831
  } catch (e) {
742
832
  if (log) log.warn('logout_failed', { message: e.message });
743
833
  }
834
+ try { chat.destroy && chat.destroy(); } catch {}
835
+ chat = null;
744
836
  }
745
837
  }
746
838
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seamnet/client",
3
- "version": "0.13.0",
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",