zen-gitsync 2.16.37 → 2.16.39

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.
@@ -10,7 +10,7 @@
10
10
  <link rel="preconnect" href="https://fonts.googleapis.com" />
11
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
12
  <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
13
- <script type="module" crossorigin src="/assets/index-CupPXceE.js"></script>
13
+ <script type="module" crossorigin src="/assets/index-hyzLLFKf.js"></script>
14
14
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-BM3Ffeng.js">
15
15
  <link rel="modulepreload" crossorigin href="/assets/monaco-43aputPh.js">
16
16
  <link rel="modulepreload" crossorigin href="/assets/element-plus-Cchqf1s7.js">
@@ -48,7 +48,7 @@ import { registerInstancesRoutes } from './routes/instances.js';
48
48
  import { registerMonitorRoutes } from './routes/monitor.js';
49
49
  import { registerMindmapRoutes } from './routes/mindmap.js';
50
50
  import { registerAgentRoutes } from './routes/workbench/agentRoutes.js';
51
- import { createInstanceRegistry } from './utils/instanceRegistry.js';
51
+ import { createInstanceRegistry, getRegistryPath } from './utils/instanceRegistry.js';
52
52
  import { createSavePortToFile } from './utils/createSavePortToFile.js';
53
53
  import { createOriginGuard, createOriginCheckerFromEnv } from './middleware/originGuard.js';
54
54
  import { startServerOnAvailablePort } from './utils/startServerOnAvailablePort.js';
@@ -229,12 +229,15 @@ async function startUIServer(noOpen = false, savePort = false) {
229
229
  registerSocketIO(io);
230
230
 
231
231
  // 构建实例注册表(用于跨进程共享"当前运行中的 GUI"信息)
232
- // 注册表文件位于用户主目录,所有 g ui 进程共享写入
232
+ // 8b725c5c 起改为"每实例一个文件"目录方案:register/heartbeat/unregister
233
+ // 只读写 *自己* 的 <pid>.json,跨进程并发永不互相覆盖。路径用 instanceRegistry
234
+ // 暴露的 getRegistryPath() 统一,避免再次出现"硬编码 .json 路径传给目录方案"
235
+ // 这种错配(8b725c5c 漏改 server/index.js 导致的 ENOTDIR scandir 报错)。
233
236
  const instanceRegistry = createInstanceRegistry({
234
237
  fs,
235
238
  path,
236
239
  os,
237
- registryPath: path.join(os.homedir(), '.zen-gitsync-instances.json')
240
+ registryPath: getRegistryPath()
238
241
  });
239
242
 
240
243
  // 注册全局错误处理器:任意未捕获异常/拒绝都会触发 fatalExit,
@@ -13,27 +13,44 @@
13
13
  // limitations under the License.
14
14
  //
15
15
  // 实例注册表工具
16
- // 维护 ~/.zen-gitsync-instances.json,记录所有正在运行的 GUI 实例
17
- // 多进程并发写采用 atomic temp+rename + 进程内串行化 Promise 链
18
- // stale 判定:PID 不存在 或 lastHeartbeat 超过阈值
16
+ // 每个 GUI 实例在自己的 <registryPath>/<pid>.json 文件中维护自身条目,
17
+ // 跨进程并发启动时不再因为 read-modify-write 互相覆盖而丢条目。
18
+ //
19
+ // 旧版本是单文件 `~/.zen-gitsync-instances.json`,多进程 register/heartbeat
20
+ // 并发时 read-modify-write 整体不原子,后写会覆盖先写,启动时一同拉起的 N
21
+ // 个实例最终只会有少数几个幸运的 PID 留在表里。改成目录后,写操作天然独
22
+ // 立:register 只动自己的 entry 文件,unregister 只删自己的,readAll 是
23
+ // 遍历目录做合并。
24
+ //
25
+ // stale 判定:PID 不存在 或 lastHeartbeat 超过 STALE_MS。
26
+ // 启动时会自动从旧主文件(~/.zen-gitsync-instances.json)迁移到目录。
19
27
 
20
28
  import nodePath from 'node:path';
21
- import logger from './logger.js'
29
+ import logger from './logger.js';
22
30
  import nodeOs from 'node:os';
23
31
 
24
- const STALE_MS = 30_000; // 心跳超时阈值(毫秒)
25
- const WATCH_DEBOUNCE_MS = 100; // fs.watch 防抖时间
32
+ const STALE_MS = 30_000; // 心跳超时阈值(毫秒)
33
+ const WATCH_DEBOUNCE_MS = 100; // fs.watch 防抖时间
26
34
  const REGISTRY_VERSION = 1;
35
+ const REGISTRY_DIR_NAME = '.zen-gitsync-instances';
36
+ const REGISTRY_LEGACY_FILE_NAME = '.zen-gitsync-instances.json';
37
+ const MIGRATION_MARKER = '.migrated';
27
38
 
28
39
  /**
29
- * 默认注册表文件路径(与 createInstanceRegistry({ registryPath }) 的约定一致)
30
- * 用于:npm.js 的 /api/app-restart 自拉起时,父进程轮询此路径判断子进程是否就绪。
40
+ * 新注册表目录路径(每进程一个文件)
31
41
  */
32
42
  export function getRegistryPath() {
33
- return nodePath.join(nodeOs.homedir(), '.zen-gitsync-instances.json');
43
+ return nodePath.join(nodeOs.homedir(), REGISTRY_DIR_NAME);
44
+ }
45
+
46
+ /**
47
+ * 旧版单文件注册表路径(仅用于一次性迁移)
48
+ */
49
+ export function getLegacyRegistryPath() {
50
+ return nodePath.join(nodeOs.homedir(), REGISTRY_LEGACY_FILE_NAME);
34
51
  }
35
52
 
36
- function isProcessAlive(pid) {
53
+ function defaultIsProcessAlive(pid) {
37
54
  if (!Number.isInteger(pid) || pid <= 0) return false;
38
55
  try {
39
56
  // 信号 0 仅做存活检查,不真正发送信号
@@ -64,11 +81,38 @@ async function resolveProjectName(projectPath, fsMod, pathMod) {
64
81
  return pathMod.basename(projectPath);
65
82
  }
66
83
 
67
- export function createInstanceRegistry({ fs: fsMod, path: pathMod, os: osMod, registryPath }) {
84
+ // entry 文件名匹配:<pid>.json(pid 是正整数)
85
+ function isEntryFileName(name) {
86
+ if (!name || typeof name !== 'string') return false;
87
+ if (name.startsWith('.')) return false; // 跳过 .migrated 等
88
+ if (name.endsWith('.tmp')) return false; // 跳过写入中的临时文件
89
+ if (!name.endsWith('.json')) return false;
90
+ const base = name.slice(0, -'.json'.length);
91
+ return /^\d+$/.test(base);
92
+ }
93
+
94
+ function pidFromFileName(name) {
95
+ return Number(name.slice(0, -'.json'.length));
96
+ }
97
+
98
+ export function createInstanceRegistry({
99
+ fs: fsMod,
100
+ path: pathMod,
101
+ os: osMod,
102
+ registryPath,
103
+ isProcessAlive: isProcessAliveOverride,
104
+ }) {
68
105
  if (!fsMod || !pathMod || !osMod || !registryPath) {
69
106
  throw new Error('createInstanceRegistry: 必须提供 fs/path/os/registryPath');
70
107
  }
71
108
 
109
+ // entry / legacy / marker 路径用注入的 pathMod 而非模块级的 nodePath,
110
+ // 这样测试里可以传跨平台的 mock path,避免 Windows 上 nodePath.join 生成
111
+ // 反斜杠路径而 mock fs 用的 '/' 路径读不到
112
+ const entryFilePath = (pid) => pathMod.join(registryPath, `${pid}.json`);
113
+ const legacyPath = () => pathMod.join(osMod.homedir(), REGISTRY_LEGACY_FILE_NAME);
114
+ const migrationMarkerPath = () => pathMod.join(registryPath, MIGRATION_MARKER);
115
+
72
116
  // 进程内写串行化:所有 mutate 操作都 await 这条链
73
117
  let writeChain = Promise.resolve();
74
118
 
@@ -79,32 +123,136 @@ export function createInstanceRegistry({ fs: fsMod, path: pathMod, os: osMod, re
79
123
  return next;
80
124
  }
81
125
 
82
- async function readAll() {
126
+ // 是否启用测试桩:若传入了 isProcessAlive,就用它(避免测试里 process.kill
127
+ // 误杀/找不到 PID 把所有 entry 都 prune 掉);否则用真实 process.kill。
128
+ const isProcessAlive = typeof isProcessAliveOverride === 'function'
129
+ ? isProcessAliveOverride
130
+ : defaultIsProcessAlive;
131
+
132
+ // 确保注册表目录存在
133
+ async function ensureDir() {
83
134
  try {
84
- const raw = await fsMod.readFile(registryPath, 'utf-8');
135
+ await fsMod.mkdir(registryPath, { recursive: true });
136
+ } catch (err) {
137
+ if (err && err.code === 'EEXIST') return;
138
+ throw err;
139
+ }
140
+ }
141
+
142
+ // 读单个 entry 文件。失败/不存在时返回 null。
143
+ async function readEntry(pid) {
144
+ const fp = entryFilePath(pid);
145
+ try {
146
+ const raw = await fsMod.readFile(fp, 'utf-8');
85
147
  const parsed = JSON.parse(raw);
86
- if (parsed && typeof parsed === 'object' && parsed.instances && typeof parsed.instances === 'object') {
87
- return parsed;
88
- }
89
- return { version: REGISTRY_VERSION, instances: {} };
148
+ if (parsed && typeof parsed === 'object') return parsed;
149
+ return null;
150
+ } catch (err) {
151
+ if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return null;
152
+ logger.warn(`[instanceRegistry] 读 entry pid=${pid} 失败: ${err?.message || err}`);
153
+ return null;
154
+ }
155
+ }
156
+
157
+ // 原子写单个 entry 文件:tmp + rename
158
+ async function writeEntry(entry) {
159
+ const fp = entryFilePath(entry.pid);
160
+ const tmpFp = `${fp}.tmp`;
161
+ await fsMod.writeFile(tmpFp, JSON.stringify(entry, null, 2), 'utf-8');
162
+ await fsMod.rename(tmpFp, fp);
163
+ }
164
+
165
+ // 删除单个 entry 文件
166
+ async function deleteEntry(pid) {
167
+ const fp = entryFilePath(pid);
168
+ try {
169
+ await fsMod.unlink(fp);
170
+ } catch (err) {
171
+ if (err && err.code === 'ENOENT') return;
172
+ throw err;
173
+ }
174
+ }
175
+
176
+ // 遍历目录读取所有 entry,合并成单张表
177
+ async function readAll() {
178
+ await ensureDir();
179
+ let names;
180
+ try {
181
+ names = await fsMod.readdir(registryPath);
90
182
  } catch (err) {
91
183
  if (err && err.code === 'ENOENT') {
92
184
  return { version: REGISTRY_VERSION, instances: {} };
93
185
  }
94
- logger.warn(`[instanceRegistry] 读取注册表失败,按空表处理: ${err?.message || err}`);
95
- return { version: REGISTRY_VERSION, instances: {} };
186
+ throw err;
187
+ }
188
+ const instances = {};
189
+ for (const name of names) {
190
+ if (!isEntryFileName(name)) continue;
191
+ const pid = pidFromFileName(name);
192
+ const entry = await readEntry(pid);
193
+ if (entry && Number.isInteger(entry.pid)) {
194
+ instances[String(entry.pid)] = entry;
195
+ }
96
196
  }
197
+ return { version: REGISTRY_VERSION, instances };
97
198
  }
98
199
 
99
- async function writeAll(obj) {
100
- const payload = {
101
- version: REGISTRY_VERSION,
102
- ...obj,
103
- instances: obj.instances || {}
104
- };
105
- const tmpPath = `${registryPath}.tmp`;
106
- await fsMod.writeFile(tmpPath, JSON.stringify(payload, null, 2), 'utf-8');
107
- await fsMod.rename(tmpPath, registryPath);
200
+ // 一次性迁移:从旧主文件 ~/.zen-gitsync-instances.json 把每个 entry
201
+ // 写到新目录里。已迁移过(存在 .migrated 标记)则跳过。
202
+ async function migrateLegacy() {
203
+ await ensureDir();
204
+ const markerPath = migrationMarkerPath();
205
+ try {
206
+ await fsMod.access(markerPath);
207
+ return false; // 已迁移
208
+ } catch (_) {
209
+ // 标记不存在,需要迁移
210
+ }
211
+
212
+ // legacy path 走注入的 osMod,这样测试里可以把 homedir 指向 mock 路径
213
+ const legacyPath_ = legacyPath();
214
+ let legacy = null;
215
+ try {
216
+ const raw = await fsMod.readFile(legacyPath_, 'utf-8');
217
+ legacy = JSON.parse(raw);
218
+ } catch (err) {
219
+ if (err && err.code !== 'ENOENT') {
220
+ logger.warn(`[instanceRegistry] 读旧主文件失败: ${err?.message || err}`);
221
+ }
222
+ }
223
+
224
+ if (!legacy || typeof legacy !== 'object' || !legacy.instances) {
225
+ // 没有旧数据,只写标记
226
+ try {
227
+ await fsMod.writeFile(markerPath, new Date().toISOString(), 'utf-8');
228
+ } catch (_) {}
229
+ return false;
230
+ }
231
+
232
+ let count = 0;
233
+ for (const [pidStr, entry] of Object.entries(legacy.instances)) {
234
+ if (!entry || !Number.isInteger(entry.pid)) continue;
235
+ try {
236
+ await writeEntry(entry);
237
+ count++;
238
+ } catch (e) {
239
+ logger.warn(`[instanceRegistry] 迁移 entry pid=${pidStr} 失败: ${e?.message || e}`);
240
+ }
241
+ }
242
+
243
+ try {
244
+ await fsMod.writeFile(markerPath, new Date().toISOString(), 'utf-8');
245
+ } catch (e) {
246
+ logger.warn(`[instanceRegistry] 写迁移标记失败: ${e?.message || e}`);
247
+ }
248
+ try {
249
+ await fsMod.unlink(legacyPath_);
250
+ } catch (e) {
251
+ // 旧文件删不掉不阻塞,只是留个垃圾,下回再删
252
+ logger.warn(`[instanceRegistry] 删旧主文件失败: ${e?.message || e}`);
253
+ }
254
+ logger.info(`[instanceRegistry] 已从旧主文件迁移 ${count} 个 entry 到目录格式`);
255
+ return true;
108
256
  }
109
257
 
110
258
  // 同步裁剪:传入当前内存中的 instances 字典,返回裁剪后的新字典
@@ -122,6 +270,9 @@ export function createInstanceRegistry({ fs: fsMod, path: pathMod, os: osMod, re
122
270
  }
123
271
 
124
272
  // 公开 API
273
+ //
274
+ // 关键点:register / heartbeat / unregister 都只读写 *自己* 的 entry 文件,
275
+ // 跨进程并发时不同 PID 的写入互不干扰,从根本上消除 read-modify-write 覆盖。
125
276
  async function register({ pid, port, projectPath, projectName, hostname } = {}) {
126
277
  if (!Number.isInteger(pid) || pid <= 0) throw new Error('register: pid 必填');
127
278
  if (!Number.isInteger(port) || port <= 0) throw new Error('register: port 必填');
@@ -142,9 +293,9 @@ export function createInstanceRegistry({ fs: fsMod, path: pathMod, os: osMod, re
142
293
  };
143
294
 
144
295
  await enqueueWrite(async () => {
145
- const obj = await readAll();
146
- obj.instances[String(pid)] = entry;
147
- await writeAll(obj);
296
+ await ensureDir();
297
+ await migrateLegacy();
298
+ await writeEntry(entry);
148
299
  });
149
300
  return entry;
150
301
  }
@@ -152,35 +303,31 @@ export function createInstanceRegistry({ fs: fsMod, path: pathMod, os: osMod, re
152
303
  async function unregister(pid) {
153
304
  if (!Number.isInteger(pid) || pid <= 0) return;
154
305
  await enqueueWrite(async () => {
155
- const obj = await readAll();
156
- if (obj.instances && obj.instances[String(pid)]) {
157
- delete obj.instances[String(pid)];
158
- await writeAll(obj);
159
- }
306
+ await ensureDir();
307
+ await deleteEntry(pid);
160
308
  });
161
309
  }
162
310
 
163
311
  // 心跳。updates 可携带完整注册信息(port/projectPath/projectName/hostname):
164
312
  // 条目存在 → 刷新 lastHeartbeat
165
313
  // 条目不存在 → 用完整信息自愈重建
166
- // 为什么要自愈:register() 是 read-modify-write,而 enqueueWrite 只串行化
167
- // 单进程内的写。多实例几乎同时启动时,两个进程都读到旧表再各自整表写回,
168
- // 后写覆盖先写,先注册的实例被静默抹掉(temp+rename 只保证单次写原子,
169
- // 读-改-写整体不原子)。被覆盖的实例不会自己恢复,这里借 5s 心跳兜底补回。
314
+ //
315
+ // 这里只读 *自己* 的 entry 文件,不遍历全表。即使全表被同时写,
316
+ // 也只影响这一个文件,自己的 lastHeartbeat 一定能更新到。
170
317
  async function heartbeat(pid, updates = {}) {
171
318
  if (!Number.isInteger(pid) || pid <= 0) return;
172
319
  await enqueueWrite(async () => {
173
- const obj = await readAll();
174
- const key = String(pid);
175
- const existing = obj.instances[key];
320
+ await ensureDir();
321
+ await migrateLegacy();
176
322
 
323
+ const existing = await readEntry(pid);
324
+ let entry;
177
325
  if (!existing) {
178
- // 信息不全就无法自愈,保持原行为(跳过)
179
326
  const canRebuild =
180
327
  Number.isInteger(updates.port) && updates.port > 0 && !!updates.projectPath;
181
328
  if (!canRebuild) return;
182
329
 
183
- obj.instances[key] = {
330
+ entry = {
184
331
  pid,
185
332
  port: updates.port,
186
333
  projectName: updates.projectName || '',
@@ -189,21 +336,19 @@ export function createInstanceRegistry({ fs: fsMod, path: pathMod, os: osMod, re
189
336
  lastHeartbeat: Date.now(),
190
337
  hostname: updates.hostname || osMod.hostname()
191
338
  };
192
- await writeAll(obj);
193
339
  logger.info(
194
340
  `[instanceRegistry] 心跳检测到本实例条目丢失,已自愈重建 pid=${pid} port=${updates.port}`
195
341
  );
196
- return;
342
+ } else {
343
+ entry = {
344
+ ...existing,
345
+ ...(updates.projectPath ? { projectPath: updates.projectPath } : {}),
346
+ ...(updates.projectName ? { projectName: updates.projectName } : {}),
347
+ ...(Number.isInteger(updates.port) && updates.port > 0 ? { port: updates.port } : {}),
348
+ lastHeartbeat: Date.now()
349
+ };
197
350
  }
198
-
199
- obj.instances[key] = {
200
- ...existing,
201
- ...(updates.projectPath ? { projectPath: updates.projectPath } : {}),
202
- ...(updates.projectName ? { projectName: updates.projectName } : {}),
203
- ...(Number.isInteger(updates.port) && updates.port > 0 ? { port: updates.port } : {}),
204
- lastHeartbeat: Date.now()
205
- };
206
- await writeAll(obj);
351
+ await writeEntry(entry);
207
352
  });
208
353
  }
209
354
 
@@ -211,15 +356,15 @@ export function createInstanceRegistry({ fs: fsMod, path: pathMod, os: osMod, re
211
356
  const obj = await readAll();
212
357
  let instances = obj.instances || {};
213
358
  if (pruneStale) {
359
+ const originalKeys = Object.keys(instances);
214
360
  instances = pruneInPlace(instances);
215
- // 如果发生了裁剪,持久化回去
216
- const hasChange = Object.keys(instances).length !== Object.keys(obj.instances || {}).length;
217
- if (hasChange) {
218
- await enqueueWrite(async () => {
219
- const fresh = await readAll();
220
- fresh.instances = pruneInPlace(fresh.instances || {});
221
- await writeAll(fresh);
222
- });
361
+ const newKeys = Object.keys(instances);
362
+ if (originalKeys.length !== newKeys.length) {
363
+ // 失效条目的文件也要删,避免堆积
364
+ const removed = originalKeys.filter((k) => !newKeys.includes(k));
365
+ for (const pidStr of removed) {
366
+ await deleteEntry(Number(pidStr));
367
+ }
223
368
  }
224
369
  }
225
370
  const arr = Object.values(instances);
@@ -227,7 +372,7 @@ export function createInstanceRegistry({ fs: fsMod, path: pathMod, os: osMod, re
227
372
  return arr;
228
373
  }
229
374
 
230
- // 监听注册表文件变化;callback 会在 debounce 后被调用,参数是最新 list
375
+ // 监听注册表目录变化;callback 会在 debounce 后被调用,参数是最新 list
231
376
  // fsWatch 参数:node 'fs' 模块的 watch 函数(同步 + EventEmitter 形式)
232
377
  function watch(callback, fsWatch) {
233
378
  if (typeof callback !== 'function') {
@@ -253,13 +398,14 @@ export function createInstanceRegistry({ fs: fsMod, path: pathMod, os: osMod, re
253
398
  };
254
399
 
255
400
  // 周期性 prune:即便没有其他进程写入注册表,也定期清理本地失效条目
256
- // (例如所有 server 都强 kill 后,文件里残留的僵尸条目会被自动清理)
401
+ // (例如所有 server 都强 kill 后,目录里残留的僵尸 entry 会被自动清理)
257
402
  pruneTimer = setInterval(() => {
258
403
  if (closed) return;
259
404
  list({ pruneStale: true }).catch(() => {});
260
405
  }, STALE_MS / 3);
261
406
 
262
407
  try {
408
+ // 监听整个目录:任何一个 entry 文件增/删/改都会触发回调
263
409
  watcher = fsWatch(registryPath, { persistent: false }, () => {
264
410
  if (closed) return;
265
411
  if (debounceTimer) clearTimeout(debounceTimer);
@@ -303,6 +449,8 @@ export function createInstanceRegistry({ fs: fsMod, path: pathMod, os: osMod, re
303
449
  watch,
304
450
  close,
305
451
  _resolveProjectName: (p) => resolveProjectName(p, fsMod, pathMod),
306
- _STALE_MS: STALE_MS
452
+ _STALE_MS: STALE_MS,
453
+ _migrateLegacy: migrateLegacy,
454
+ _isEntryFileName: isEntryFileName
307
455
  };
308
456
  }