@seamnet/client 0.13.2 → 0.13.4

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/lib/guardian.js CHANGED
@@ -130,6 +130,19 @@ export async function guardianRun() {
130
130
  process.exit(1);
131
131
  }
132
132
 
133
+ // 单例保证:检查 PID 文件,如已有活进程则退出
134
+ if (isGuardianRunning()) {
135
+ const existingPid = readGuardianPid();
136
+ console.error(`Guardian already running (pid: ${existingPid}). Exiting.`);
137
+ process.exit(0);
138
+ }
139
+ // 写 PID,退出时清理
140
+ writeFileSync(PID_PATH, String(process.pid));
141
+ const cleanPid = () => { try { if (existsSync(PID_PATH)) unlinkSync(PID_PATH); } catch {} };
142
+ process.on('exit', cleanPid);
143
+ process.on('SIGTERM', () => { cleanPid(); process.exit(0); });
144
+ process.on('SIGINT', () => { cleanPid(); process.exit(0); });
145
+
133
146
  const credentials = JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf8'));
134
147
  const ccSession = process.env.SEAM_CC_SESSION || '';
135
148
  const ccSocket = process.env.SEAM_CC_SOCKET || resolveTmuxSocketPath() || '';
@@ -74,6 +74,19 @@ const { validatePayload } = require('../../contracts/actions.cjs');
74
74
  }
75
75
  })();
76
76
 
77
+ // SDK 依赖 window.addEventListener("online") 触发内部 reConnect()。
78
+ // 用真实 EventEmitter 让 SDK 注册的 listener 能被 emit 调用。
79
+ const { EventEmitter: _PolyfillEmitter } = require('node:events');
80
+ const _windowEvents = new _PolyfillEmitter();
81
+ const _documentEvents = new _PolyfillEmitter();
82
+
83
+ // SDK 的 NetMonitor 检查 previous !== current 才 reConnect。
84
+ // 必须先 offline 再 online,否则 online→online 不触发。
85
+ function emitNetworkRecovery() {
86
+ _windowEvents.emit('offline');
87
+ setImmediate(() => _windowEvents.emit('online'));
88
+ }
89
+
77
90
  function setupSdkGlobals() {
78
91
  if (global._imSdkGlobalsSet) return;
79
92
  global._imSdkGlobalsSet = true;
@@ -84,8 +97,8 @@ function setupSdkGlobals() {
84
97
  host: 'localhost',
85
98
  hostname: 'localhost',
86
99
  },
87
- addEventListener: () => {},
88
- removeEventListener: () => {},
100
+ addEventListener: (type, fn) => _windowEvents.on(type, fn),
101
+ removeEventListener: (type, fn) => _windowEvents.off(type, fn),
89
102
  URL: Object.assign(
90
103
  function (...a) {
91
104
  return new (require('node:url').URL)(...a);
@@ -97,8 +110,8 @@ function setupSdkGlobals() {
97
110
  ),
98
111
  };
99
112
  global.document = {
100
- addEventListener: () => {},
101
- removeEventListener: () => {},
113
+ addEventListener: (type, fn) => _documentEvents.on(type, fn),
114
+ removeEventListener: (type, fn) => _documentEvents.off(type, fn),
102
115
  characterSet: 'UTF-8',
103
116
  };
104
117
  global.navigator = { userAgent: 'node', language: 'en', platform: 'linux' };
@@ -147,7 +160,7 @@ function createImPlugin() {
147
160
  // 重建失败后自驱重试:指数退避 + jitter;成功后重置
148
161
  let rebuildRetryAttempt = 0;
149
162
  let rebuildRetryTimer = null;
150
- const REBUILD_COOLDOWN_MS = 10_000;
163
+ const REBUILD_COOLDOWN_MS = 60_000;
151
164
  const REBUILD_DEBOUNCE_MS = 3_000;
152
165
  const REBUILD_WATCHDOG_MS = 30_000; // 整个重建最多 30s,超时强制失败
153
166
  const SDK_CALL_TIMEOUT_MS = 10_000; // 单个 SDK 调用超时
@@ -158,8 +171,9 @@ function createImPlugin() {
158
171
  const KEEPALIVE_TIMEOUT_MS = 10_000;
159
172
  // 重建失败退避表(最后一档持续)
160
173
  const REBUILD_RETRY_BACKOFF_MS = [3_000, 10_000, 30_000, 60_000];
161
- // SDK 错误 code 需要触发 rebuild(2801=PING 超时,WebSocket 应用层僵尸)
162
- const SDK_REBUILD_ERROR_CODES = new Set([2801]);
174
+ // 不再主动 rebuild 2801——SDK 内部有自己的重连,0.12.7 证明十几天不断。
175
+ // 0.13.0 rebuild 反而制造僵尸循环。只靠 keepalive(3min)和 kicked_out 兜底。
176
+ const SDK_REBUILD_ERROR_CODES = new Set();
163
177
 
164
178
  function withTimeout(promise, ms, name) {
165
179
  let tid = null;
@@ -370,13 +384,17 @@ function createImPlugin() {
370
384
  return {
371
385
  onSdkReady: () => {
372
386
  if (gen !== chatGeneration) return;
373
- // sdkReady 的置 true 统一在 rebuildChat/init 成功 swap 后做,handler 只记录
387
+ // SDK 自愈成功(reConnect rebuild 后)
388
+ sdkReady = true;
389
+ clearNotReadyGrace();
374
390
  log.info('sdk_ready', { userId: myUserId, gen });
375
391
  },
376
392
  onSdkNotReady: () => {
377
393
  if (gen !== chatGeneration) return;
378
394
  sdkReady = false;
379
395
  log.warn('sdk_not_ready', { gen });
396
+ // 启动 grace timer:5 分钟没自愈就 rebuild
397
+ startNotReadyGrace();
380
398
  },
381
399
  onSdkError: (event) => {
382
400
  if (gen !== chatGeneration) return;
@@ -566,11 +584,12 @@ function createImPlugin() {
566
584
  // ===== 候选模式构建新 chat(失败则隔离销毁,不污染 active chat)=====
567
585
  async function buildChatCandidate() {
568
586
  const myGen = ++chatGeneration;
569
- // globals,让 setupSdkGlobals 重新铺 window 等
587
+ // 先铺 window(带真实 EventEmitter),再 create——
588
+ // SDK 在 create 时注册 window.addEventListener("online", reConnect)
570
589
  delete global.window;
571
590
  global._imSdkGlobalsSet = false;
572
- const nextChat = TencentCloudChat.create({ SDKAppID: Number(credentialsRef.sdkAppId) });
573
591
  setupSdkGlobals();
592
+ const nextChat = TencentCloudChat.create({ SDKAppID: Number(credentialsRef.sdkAppId) });
574
593
  try {
575
594
  const TIMUploadPlugin = require('tim-upload-plugin');
576
595
  nextChat.registerPlugin({ 'tim-upload-plugin': TIMUploadPlugin });
@@ -689,15 +708,51 @@ function createImPlugin() {
689
708
  setTimeout(() => { doRebuild(reason).catch(() => {}); }, REBUILD_DEBOUNCE_MS);
690
709
  }
691
710
 
711
+ let keepaliveFailCount = 0;
712
+ let notReadyGraceTimer = null;
713
+ const NOT_READY_GRACE_MS = 5 * 60 * 1000; // SDK_NOT_READY 后 5 分钟没恢复就 rebuild
714
+
715
+ function startNotReadyGrace() {
716
+ if (notReadyGraceTimer) return;
717
+ notReadyGraceTimer = setTimeout(() => {
718
+ notReadyGraceTimer = null;
719
+ if (!sdkReady && !rebuilding && !rebuildScheduled) {
720
+ log.warn('not_ready_grace_expired');
721
+ scheduleRebuild('not_ready_grace_expired');
722
+ }
723
+ }, NOT_READY_GRACE_MS);
724
+ }
725
+
726
+ function clearNotReadyGrace() {
727
+ if (notReadyGraceTimer) { clearTimeout(notReadyGraceTimer); notReadyGraceTimer = null; }
728
+ }
729
+
692
730
  function startKeepalive() {
693
731
  if (keepaliveTimer) return;
694
732
  keepaliveTimer = setInterval(async () => {
695
- if (!sdkReady || rebuilding || rebuildScheduled) return;
733
+ if (rebuilding || rebuildScheduled) return;
734
+ // sdkReady=false 时也检测(NOT_READY 后需要探测恢复)
735
+ if (!sdkReady) {
736
+ // 尝试 emit 网络恢复信号帮 SDK 重连
737
+ log.info('keepalive_nudge_not_ready');
738
+ emitNetworkRecovery();
739
+ return;
740
+ }
696
741
  try {
697
742
  await withTimeout(chat.getMyProfile(), KEEPALIVE_TIMEOUT_MS, 'keepalive');
743
+ keepaliveFailCount = 0;
698
744
  } catch (e) {
699
- log.warn('keepalive_failed', { message: e.message });
700
- scheduleRebuild(`keepalive:${e.message}`);
745
+ keepaliveFailCount += 1;
746
+ log.warn('keepalive_failed', { message: e.message, failCount: keepaliveFailCount });
747
+ if (keepaliveFailCount === 1) {
748
+ // 第一次失败:模拟断网恢复,让 SDK 内部 reConnect()
749
+ log.info('emitting_network_recovery');
750
+ emitNetworkRecovery();
751
+ } else {
752
+ // 连续失败:SDK 自愈没成功,升级到 rebuild
753
+ scheduleRebuild(`keepalive_consecutive:${keepaliveFailCount}`);
754
+ keepaliveFailCount = 0;
755
+ }
701
756
  }
702
757
  }, KEEPALIVE_INTERVAL_MS);
703
758
  }
@@ -769,6 +824,12 @@ function createImPlugin() {
769
824
  processRejectionHandler = (reason) => {
770
825
  const code = reason?.code;
771
826
  if (code && SDK_REBUILD_ERROR_CODES.has(code)) {
827
+ // sdkReady=true 说明当前 session 健康,2801 来自被 destroy 但没清干净的老实例
828
+ // 真正断了会被 keepalive(3min)或 onSdkError(generation-scoped)兜住
829
+ if (sdkReady) {
830
+ log.info('stale_rejection_ignored', { code });
831
+ return;
832
+ }
772
833
  scheduleRebuild(`unhandledRejection:${code}`);
773
834
  }
774
835
  };
@@ -820,6 +881,7 @@ function createImPlugin() {
820
881
  clearTimeout(rebuildRetryTimer);
821
882
  rebuildRetryTimer = null;
822
883
  }
884
+ clearNotReadyGrace();
823
885
  if (processRejectionHandler) {
824
886
  try { process.off('unhandledRejection', processRejectionHandler); } catch {}
825
887
  processRejectionHandler = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seamnet/client",
3
- "version": "0.13.2",
3
+ "version": "0.13.4",
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",