dsh-config-manager 0.1.85 → 0.1.86

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.
@@ -19,7 +19,10 @@
19
19
  * - 锁文件放 `os.tmpdir()`(按工作目录绝对路径哈希命名),**不写进工作副本**:
20
20
  * 工作副本还没 clone 出来也能先锁住,且绝不让 `git status` 看到脏文件;
21
21
  * - 跨进程独占用 `open(path,'wx')`(O_EXCL,原子);等待方轮询,超时抛 WorkdirLockError;
22
- * - holder 崩溃留下的陈旧锁按 mtime 回收(超过 staleMs 才回收,避免误抢仍在跑的长时间任务);
22
+ * - holder 崩溃留下的陈旧锁:先问 holder pid 是否还存在(ESRCH ⇒ 已被杀,立即回收),
23
+ * 读不出 pid 或进程仍存活时才按 mtime 超过 staleMs 回收(避免误抢仍在跑的长时间任务);
24
+ * - 等待方是**有界**的:等文件锁与进程内链上排队共用同一个 timeoutMs 上限,超时即抛,
25
+ * 绝不在界面早已放弃之后仍去驱动工作副本;
23
26
  * - 进程内再串一条 Promise 链:同进程两个 GitTransport 实例(autosync / 路由)共用同一条链,
24
27
  * 不依赖文件锁的轮询延迟。
25
28
  */
@@ -75,6 +78,91 @@ export function workdirLockPath(workDir: string): string {
75
78
  return path.join(os.tmpdir(), `dshcm-workdir-${workdirLockKey(workDir)}.lock`);
76
79
  }
77
80
 
81
+ /**
82
+ * holder 进程是否还活着(三态:true = 存活、false = 确定已死、null = 判不了)。
83
+ *
84
+ * 为什么必须问:holder 被强杀时(DSH 重启 / 崩溃 / 任务管理器结束进程)`finally` 不会执行,
85
+ * 锁文件就留在 tmpdir 里;只按 mtime 判定要等满 staleMs(15 分钟)才接管。这段窗口里**所有**
86
+ * 同步与只读自检都排在锁上 —— 真机 2026-10-27:DSH 重启踩在一次 push 中间,pid 28888 的锁留在
87
+ * %TEMP%,之后每次打开同步页 `GET /sync/status` 都要等满 10 s 预算再 503。
88
+ *
89
+ * 误差方向:只有 `ESRCH`(pid 完全不存在)才判死;`EPERM`(进程存在但无权发信号,如 Linux 跨用户)
90
+ * 判活;pid 复用也会把「别人的锁」判成活 —— 两种误判都偏向保守(最多退化成 mtime 规则),
91
+ * 绝不会把仍在跑的 holder 的锁抢走。
92
+ */
93
+ export function workdirLockHolderAlive(pid: unknown): boolean | null {
94
+ if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return null;
95
+ try {
96
+ process.kill(pid, 0);
97
+ return true;
98
+ } catch (err) {
99
+ const code = (err as { code?: string }).code;
100
+ if (code === 'ESRCH') return false;
101
+ if (code === 'EPERM') return true;
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /** 读锁文件里记录的 holder(读不出 / 半写入 / 不是对象 ⇒ null,一律退回 mtime 规则) */
107
+ async function readLockHolder(lockPath: string): Promise<{ pid: unknown; workDir: unknown } | null> {
108
+ try {
109
+ const parsed: unknown = JSON.parse(await fs.readFile(lockPath, 'utf8'));
110
+ if (parsed === null || typeof parsed !== 'object') return null;
111
+ const holder = parsed as { pid?: unknown; workDir?: unknown };
112
+ return { pid: holder.pid, workDir: holder.workDir };
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ const LOCK_FILE_PREFIX = 'dshcm-workdir-';
119
+ let pruneScheduled = false;
120
+
121
+ /**
122
+ * 一次性清扫本机所有「holder 已死」的工作副本锁(每进程只跑一次)。
123
+ *
124
+ * 为什么需要:pid 回收只在**自己被这把锁挡住时**生效,而残留锁可能属于早已不再使用的工作目录
125
+ * (换过仓库、换过数据目录、临时实例)—— 它们永远不会再被查询,却一直躺在 %TEMP%(真机 2026-10-27
126
+ * 见到 8 个,最久 3.4 天)。只删 pid 确定不存在的锁:读不出 holder 的不动(保守,宁可留下)。
127
+ */
128
+ export async function pruneDeadWorkdirLocksOnce(): Promise<void> {
129
+ if (pruneScheduled) return;
130
+ pruneScheduled = true;
131
+ const dir = os.tmpdir();
132
+ let names: string[];
133
+ try {
134
+ names = await fs.readdir(dir);
135
+ } catch {
136
+ return;
137
+ }
138
+ const remove = async (lockPath: string): Promise<void> => {
139
+ try {
140
+ await fs.unlink(lockPath);
141
+ } catch {
142
+ // 别人同时也在清扫 / 已被释放:无事可做
143
+ }
144
+ };
145
+ await Promise.all(names
146
+ .filter((name) => name.startsWith(LOCK_FILE_PREFIX) && name.endsWith('.lock'))
147
+ .map(async (name) => {
148
+ const lockPath = path.join(dir, name);
149
+ const holder = await readLockHolder(lockPath);
150
+ if (holder === null) return; // 读不出 holder:保守留下
151
+ const alive = workdirLockHolderAlive(holder.pid);
152
+ if (alive === false) return await remove(lockPath); // holder 确定已死
153
+ if (alive !== true) return; // 判不了:留下
154
+ // pid 复用陷阱:holder pid 此刻可能属于一个毫不相干的进程(本机实测 2026-10-27:一条测试残留锁的
155
+ // pid 9704 已被 `NoiseCancelingEngine.exe` 复用)。只有「它声明的工作目录确实不存在 + 锁已过
156
+ // staleMs」才清 —— 那说明 holder 早就不在这把锁上了,而不是正在干活。
157
+ const st = await fs.stat(lockPath).catch(() => null);
158
+ if (st === null || Date.now() - st.mtimeMs <= DEFAULT_WORKDIR_LOCK_STALE_MS) return;
159
+ const workDir = typeof holder.workDir === 'string' ? holder.workDir : '';
160
+ if (workDir === '') return;
161
+ if (await fs.stat(workDir).then(() => true, () => false)) return;
162
+ await remove(lockPath);
163
+ }));
164
+ }
165
+
78
166
  async function tryAcquire(lockPath: string, workDir: string, staleMs: number, now: () => number): Promise<(() => Promise<void>) | null> {
79
167
  const token = randomUUID();
80
168
  try {
@@ -98,7 +186,17 @@ async function tryAcquire(lockPath: string, workDir: string, staleMs: number, no
98
186
  } catch (err) {
99
187
  const code = (err as { code?: string }).code;
100
188
  if (code !== 'EEXIST') throw err;
101
- // 陈旧锁回收:holder 崩溃/被杀后留下的文件不能让同步永久卡死
189
+ // ① holder pid 已经不存在 ⇒ 这把锁必然陈旧,立即回收,不等 staleMs(重启/崩溃后的现场)
190
+ const holder = await readLockHolder(lockPath);
191
+ if (workdirLockHolderAlive(holder?.pid) === false) {
192
+ try {
193
+ await fs.unlink(lockPath);
194
+ } catch {
195
+ // 别人刚好也判定并回收、或 holder 的 finally 刚好跑到:下一轮重试即可
196
+ }
197
+ return null;
198
+ }
199
+ // ② 读不出 holder / holder 仍存活 ⇒ 只有超过 staleMs 才视为崩溃残留(绝不抢正在跑的长任务)
102
200
  try {
103
201
  const st = await fs.stat(lockPath);
104
202
  if (now() - st.mtimeMs > staleMs) await fs.unlink(lockPath);
@@ -127,8 +225,16 @@ export async function withWorkdirLock<T>(
127
225
  const key = workdirLockKey(workDir);
128
226
  const lockPath = workdirLockPath(workDir);
129
227
 
228
+ // 顺带清扫本机别的死 holder 残留(每进程一次,失败不影响本次取锁)
229
+ void pruneDeadWorkdirLocksOnce().catch(() => undefined);
230
+
231
+ // 计时必须在进入串行链**之前**取:链上排队同样属于「在等这把锁」。否则一个只读调用排在
232
+ // 上一个 300 s 的同步后面时,计时从轮到它那一刻才清零,超时上限形同失效(2026-10-27 现场)。
233
+ const started = now();
234
+
130
235
  const run = async (): Promise<T> => {
131
- const started = now();
236
+ // 排队期间调用方已超时放弃:轮到它时直接失败,**不执行 fn**,免得被遗弃的请求稍后仍去驱动工作副本
237
+ if (now() - started >= timeoutMs) throw new WorkdirLockError(workDir, now() - started);
132
238
  // 先同步做一次尝试,再进入轮询:无竞争的常见情形零额外延迟
133
239
  let release = await tryAcquire(lockPath, workDir, staleMs, now);
134
240
  while (release === null) {
@@ -149,14 +255,25 @@ export async function withWorkdirLock<T>(
149
255
  // 链里只保留「尾巴」的完成状态,异常不向下游传染
150
256
  const tail = current.then(() => undefined, () => undefined);
151
257
  chains.set(key, tail);
258
+
259
+ // 硬上界:timeoutMs 是「拿到锁」的上限,不是「开始计时后」的上限。只用 run 内的计时不够 ——
260
+ // 排在长任务后面的调用永远轮不到自己计时,界面就被历史任务拖着走。这里直接对链头 race,
261
+ // 超时 reject;被放弃的 run 之后还会被链唤醒,但入口的过期检查让它立刻抛(不碰工作副本)。
262
+ let rejectDeadline: ((reason: Error) => void) | undefined;
263
+ const deadline = new Promise<never>((_, reject) => { rejectDeadline = reject; });
264
+ const timer = setTimeout(() => { rejectDeadline?.(new WorkdirLockError(workDir, timeoutMs)); }, timeoutMs);
265
+ // unref:这个计时器不该拖住进程退出(缺省 300 s)
266
+ if (typeof timer.unref === 'function') timer.unref();
152
267
  try {
153
- return await current;
268
+ return await Promise.race([current, deadline]);
154
269
  } finally {
270
+ clearTimeout(timer);
155
271
  if (chains.get(key) === tail) chains.delete(key);
156
272
  }
157
273
  }
158
274
 
159
- /** 仅供测试:清空进程内串行链(不影响磁盘上的锁文件) */
275
+ /** 仅供测试:清空进程内串行链与一次性清扫标记(不影响磁盘上的锁文件) */
160
276
  export function resetWorkdirLockChainsForTest(): void {
161
277
  chains.clear();
278
+ pruneScheduled = false;
162
279
  }