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.
- package/lib/client.js +514 -514
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/sync/git/git-transport.js +18 -3
- package/lib/utils/workdir-lock.d.ts +26 -2
- package/lib/utils/workdir-lock.js +133 -5
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/sync/git/git-transport.test.ts +32 -0
- package/src/sync/git/git-transport.ts +20 -4
- package/src/utils/workdir-lock.test.ts +77 -4
- package/src/utils/workdir-lock.ts +122 -5
package/lib/index.d.ts
CHANGED
|
@@ -77,7 +77,7 @@ export declare const name = "config-manager";
|
|
|
77
77
|
/** Services required before the engine can mount (present in every profile). */
|
|
78
78
|
export declare const inject: string[];
|
|
79
79
|
/** Plugin version, kept in sync with package.json ("version"). */
|
|
80
|
-
export declare const PLUGIN_VERSION = "0.1.
|
|
80
|
+
export declare const PLUGIN_VERSION = "0.1.86";
|
|
81
81
|
/**
|
|
82
82
|
* Star 引导弹窗指向的 GitHub 仓库(用户引导点 Star 的目标)。
|
|
83
83
|
* 与 package.json 的 repository 字段保持一致;界面不可改(硬编码,参照
|
package/lib/index.js
CHANGED
|
@@ -136,7 +136,7 @@ export const name = 'config-manager';
|
|
|
136
136
|
/** Services required before the engine can mount (present in every profile). */
|
|
137
137
|
export const inject = ['settings', 'credentials'];
|
|
138
138
|
/** Plugin version, kept in sync with package.json ("version"). */
|
|
139
|
-
export const PLUGIN_VERSION = '0.1.
|
|
139
|
+
export const PLUGIN_VERSION = '0.1.86';
|
|
140
140
|
/** Plugin own package name — excluded from its own exported plugins list. */
|
|
141
141
|
const PLUGIN_NAME = 'dsh-config-manager';
|
|
142
142
|
/**
|
|
@@ -53,6 +53,15 @@ const execFileAsync = promisify(execFile);
|
|
|
53
53
|
*/
|
|
54
54
|
const INSPECT_FACTS_TTL_MS = 10_000;
|
|
55
55
|
const inspectFactsCache = new Map();
|
|
56
|
+
/**
|
|
57
|
+
* 只读自检等锁的上限(2026-10-27)。
|
|
58
|
+
*
|
|
59
|
+
* 为什么和写路径不同:写操作本来就该等(一次 push 十几秒是正常的),但自检挂在
|
|
60
|
+
* `GET /sync/status` 上,同步任务持锁期间它等待是**纯损失** —— 界面要的只是「工作副本有没有跟随
|
|
61
|
+
* 当前地址」,拿不到锁就如实报忙,让请求有界返回(再退一步还有 10 s 的接口预算兜底)。此前它沿用
|
|
62
|
+
* 缺省的 300 s:一次自动同步持锁就能让同步页每次打开都白等 10 s(真机 2026-10-27)。
|
|
63
|
+
*/
|
|
64
|
+
const INSPECT_WORKDIR_LOCK_TIMEOUT_MS = 1_500;
|
|
56
65
|
function inspectFactsCacheKey(workDir, repoUrl) {
|
|
57
66
|
return workDir + '\u0000' + repoUrl;
|
|
58
67
|
}
|
|
@@ -423,6 +432,8 @@ export class GitTransport {
|
|
|
423
432
|
}
|
|
424
433
|
}
|
|
425
434
|
try {
|
|
435
|
+
// 只读自检传短上限:它挂在状态接口上,等不起一次同步(见 INSPECT_WORKDIR_LOCK_TIMEOUT_MS);
|
|
436
|
+
// 写路径不传,沿用缺省的 300 s。
|
|
426
437
|
return await withWorkdirLock(this.o.workDir, async () => {
|
|
427
438
|
this.workdirLockDepth += 1;
|
|
428
439
|
try {
|
|
@@ -431,7 +442,7 @@ export class GitTransport {
|
|
|
431
442
|
finally {
|
|
432
443
|
this.workdirLockDepth -= 1;
|
|
433
444
|
}
|
|
434
|
-
});
|
|
445
|
+
}, opts.lockTimeoutMs === undefined ? {} : { timeoutMs: opts.lockTimeoutMs });
|
|
435
446
|
}
|
|
436
447
|
catch (err) {
|
|
437
448
|
// 等锁超时 = 别人正在同步(另一个 DSH 实例 / 另一个同步任务),不是通道故障:
|
|
@@ -543,8 +554,12 @@ export class GitTransport {
|
|
|
543
554
|
if (hit !== undefined && Date.now() - hit.at < INSPECT_FACTS_TTL_MS && (hit.detail || !detail)) {
|
|
544
555
|
return { ...hit.facts };
|
|
545
556
|
}
|
|
546
|
-
// 只读命令也要排队:与并发的 fetch/checkout 交错会让 status/rev-list
|
|
547
|
-
|
|
557
|
+
// 只读命令也要排队:与并发的 fetch/checkout 交错会让 status/rev-list 读到半更新状态。
|
|
558
|
+
// 但排队必须有界(短上限):拿不到锁就报「工作副本忙」,绝不把状态接口拖进 300 s 的等待。
|
|
559
|
+
const facts = await this.withWorkdir(() => this.inspectWorkDirLocked(opts), {
|
|
560
|
+
readonly: true,
|
|
561
|
+
lockTimeoutMs: INSPECT_WORKDIR_LOCK_TIMEOUT_MS,
|
|
562
|
+
});
|
|
548
563
|
const prev = inspectFactsCache.get(key);
|
|
549
564
|
if (detail || prev === undefined || !prev.detail) {
|
|
550
565
|
inspectFactsCache.set(key, { at: Date.now(), detail, facts });
|
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
* - 锁文件放 `os.tmpdir()`(按工作目录绝对路径哈希命名),**不写进工作副本**:
|
|
20
20
|
* 工作副本还没 clone 出来也能先锁住,且绝不让 `git status` 看到脏文件;
|
|
21
21
|
* - 跨进程独占用 `open(path,'wx')`(O_EXCL,原子);等待方轮询,超时抛 WorkdirLockError;
|
|
22
|
-
* - holder
|
|
22
|
+
* - holder 崩溃留下的陈旧锁:先问 holder pid 是否还存在(ESRCH ⇒ 已被杀,立即回收),
|
|
23
|
+
* 读不出 pid 或进程仍存活时才按 mtime 超过 staleMs 回收(避免误抢仍在跑的长时间任务);
|
|
24
|
+
* - 等待方是**有界**的:等文件锁与进程内链上排队共用同一个 timeoutMs 上限,超时即抛,
|
|
25
|
+
* 绝不在界面早已放弃之后仍去驱动工作副本;
|
|
23
26
|
* - 进程内再串一条 Promise 链:同进程两个 GitTransport 实例(autosync / 路由)共用同一条链,
|
|
24
27
|
* 不依赖文件锁的轮询延迟。
|
|
25
28
|
*/
|
|
@@ -47,6 +50,27 @@ export interface WorkdirLockOptions {
|
|
|
47
50
|
export declare function workdirLockKey(workDir: string): string;
|
|
48
51
|
/** 锁文件路径(导出仅为测试与诊断;不要手工删一把可能被人持有的锁) */
|
|
49
52
|
export declare function workdirLockPath(workDir: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* holder 进程是否还活着(三态:true = 存活、false = 确定已死、null = 判不了)。
|
|
55
|
+
*
|
|
56
|
+
* 为什么必须问:holder 被强杀时(DSH 重启 / 崩溃 / 任务管理器结束进程)`finally` 不会执行,
|
|
57
|
+
* 锁文件就留在 tmpdir 里;只按 mtime 判定要等满 staleMs(15 分钟)才接管。这段窗口里**所有**
|
|
58
|
+
* 同步与只读自检都排在锁上 —— 真机 2026-10-27:DSH 重启踩在一次 push 中间,pid 28888 的锁留在
|
|
59
|
+
* %TEMP%,之后每次打开同步页 `GET /sync/status` 都要等满 10 s 预算再 503。
|
|
60
|
+
*
|
|
61
|
+
* 误差方向:只有 `ESRCH`(pid 完全不存在)才判死;`EPERM`(进程存在但无权发信号,如 Linux 跨用户)
|
|
62
|
+
* 判活;pid 复用也会把「别人的锁」判成活 —— 两种误判都偏向保守(最多退化成 mtime 规则),
|
|
63
|
+
* 绝不会把仍在跑的 holder 的锁抢走。
|
|
64
|
+
*/
|
|
65
|
+
export declare function workdirLockHolderAlive(pid: unknown): boolean | null;
|
|
66
|
+
/**
|
|
67
|
+
* 一次性清扫本机所有「holder 已死」的工作副本锁(每进程只跑一次)。
|
|
68
|
+
*
|
|
69
|
+
* 为什么需要:pid 回收只在**自己被这把锁挡住时**生效,而残留锁可能属于早已不再使用的工作目录
|
|
70
|
+
* (换过仓库、换过数据目录、临时实例)—— 它们永远不会再被查询,却一直躺在 %TEMP%(真机 2026-10-27
|
|
71
|
+
* 见到 8 个,最久 3.4 天)。只删 pid 确定不存在的锁:读不出 holder 的不动(保守,宁可留下)。
|
|
72
|
+
*/
|
|
73
|
+
export declare function pruneDeadWorkdirLocksOnce(): Promise<void>;
|
|
50
74
|
/**
|
|
51
75
|
* 在指定工作副本上串行执行 fn(同一目录的调用排队,跨进程同理)。
|
|
52
76
|
*
|
|
@@ -54,5 +78,5 @@ export declare function workdirLockPath(workDir: string): string;
|
|
|
54
78
|
* 不要在里面再调用会等这把锁的其他工作副本操作 —— 可重入请由调用方用自己的 depth 计数处理。
|
|
55
79
|
*/
|
|
56
80
|
export declare function withWorkdirLock<T>(workDir: string, fn: () => Promise<T>, options?: WorkdirLockOptions): Promise<T>;
|
|
57
|
-
/**
|
|
81
|
+
/** 仅供测试:清空进程内串行链与一次性清扫标记(不影响磁盘上的锁文件) */
|
|
58
82
|
export declare function resetWorkdirLockChainsForTest(): void;
|
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
* - 锁文件放 `os.tmpdir()`(按工作目录绝对路径哈希命名),**不写进工作副本**:
|
|
20
20
|
* 工作副本还没 clone 出来也能先锁住,且绝不让 `git status` 看到脏文件;
|
|
21
21
|
* - 跨进程独占用 `open(path,'wx')`(O_EXCL,原子);等待方轮询,超时抛 WorkdirLockError;
|
|
22
|
-
* - holder
|
|
22
|
+
* - holder 崩溃留下的陈旧锁:先问 holder pid 是否还存在(ESRCH ⇒ 已被杀,立即回收),
|
|
23
|
+
* 读不出 pid 或进程仍存活时才按 mtime 超过 staleMs 回收(避免误抢仍在跑的长时间任务);
|
|
24
|
+
* - 等待方是**有界**的:等文件锁与进程内链上排队共用同一个 timeoutMs 上限,超时即抛,
|
|
25
|
+
* 绝不在界面早已放弃之后仍去驱动工作副本;
|
|
23
26
|
* - 进程内再串一条 Promise 链:同进程两个 GitTransport 实例(autosync / 路由)共用同一条链,
|
|
24
27
|
* 不依赖文件锁的轮询延迟。
|
|
25
28
|
*/
|
|
@@ -56,6 +59,102 @@ export function workdirLockKey(workDir) {
|
|
|
56
59
|
export function workdirLockPath(workDir) {
|
|
57
60
|
return path.join(os.tmpdir(), `dshcm-workdir-${workdirLockKey(workDir)}.lock`);
|
|
58
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* holder 进程是否还活着(三态:true = 存活、false = 确定已死、null = 判不了)。
|
|
64
|
+
*
|
|
65
|
+
* 为什么必须问:holder 被强杀时(DSH 重启 / 崩溃 / 任务管理器结束进程)`finally` 不会执行,
|
|
66
|
+
* 锁文件就留在 tmpdir 里;只按 mtime 判定要等满 staleMs(15 分钟)才接管。这段窗口里**所有**
|
|
67
|
+
* 同步与只读自检都排在锁上 —— 真机 2026-10-27:DSH 重启踩在一次 push 中间,pid 28888 的锁留在
|
|
68
|
+
* %TEMP%,之后每次打开同步页 `GET /sync/status` 都要等满 10 s 预算再 503。
|
|
69
|
+
*
|
|
70
|
+
* 误差方向:只有 `ESRCH`(pid 完全不存在)才判死;`EPERM`(进程存在但无权发信号,如 Linux 跨用户)
|
|
71
|
+
* 判活;pid 复用也会把「别人的锁」判成活 —— 两种误判都偏向保守(最多退化成 mtime 规则),
|
|
72
|
+
* 绝不会把仍在跑的 holder 的锁抢走。
|
|
73
|
+
*/
|
|
74
|
+
export function workdirLockHolderAlive(pid) {
|
|
75
|
+
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0)
|
|
76
|
+
return null;
|
|
77
|
+
try {
|
|
78
|
+
process.kill(pid, 0);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
const code = err.code;
|
|
83
|
+
if (code === 'ESRCH')
|
|
84
|
+
return false;
|
|
85
|
+
if (code === 'EPERM')
|
|
86
|
+
return true;
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** 读锁文件里记录的 holder(读不出 / 半写入 / 不是对象 ⇒ null,一律退回 mtime 规则) */
|
|
91
|
+
async function readLockHolder(lockPath) {
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(await fs.readFile(lockPath, 'utf8'));
|
|
94
|
+
if (parsed === null || typeof parsed !== 'object')
|
|
95
|
+
return null;
|
|
96
|
+
const holder = parsed;
|
|
97
|
+
return { pid: holder.pid, workDir: holder.workDir };
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const LOCK_FILE_PREFIX = 'dshcm-workdir-';
|
|
104
|
+
let pruneScheduled = false;
|
|
105
|
+
/**
|
|
106
|
+
* 一次性清扫本机所有「holder 已死」的工作副本锁(每进程只跑一次)。
|
|
107
|
+
*
|
|
108
|
+
* 为什么需要:pid 回收只在**自己被这把锁挡住时**生效,而残留锁可能属于早已不再使用的工作目录
|
|
109
|
+
* (换过仓库、换过数据目录、临时实例)—— 它们永远不会再被查询,却一直躺在 %TEMP%(真机 2026-10-27
|
|
110
|
+
* 见到 8 个,最久 3.4 天)。只删 pid 确定不存在的锁:读不出 holder 的不动(保守,宁可留下)。
|
|
111
|
+
*/
|
|
112
|
+
export async function pruneDeadWorkdirLocksOnce() {
|
|
113
|
+
if (pruneScheduled)
|
|
114
|
+
return;
|
|
115
|
+
pruneScheduled = true;
|
|
116
|
+
const dir = os.tmpdir();
|
|
117
|
+
let names;
|
|
118
|
+
try {
|
|
119
|
+
names = await fs.readdir(dir);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const remove = async (lockPath) => {
|
|
125
|
+
try {
|
|
126
|
+
await fs.unlink(lockPath);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// 别人同时也在清扫 / 已被释放:无事可做
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
await Promise.all(names
|
|
133
|
+
.filter((name) => name.startsWith(LOCK_FILE_PREFIX) && name.endsWith('.lock'))
|
|
134
|
+
.map(async (name) => {
|
|
135
|
+
const lockPath = path.join(dir, name);
|
|
136
|
+
const holder = await readLockHolder(lockPath);
|
|
137
|
+
if (holder === null)
|
|
138
|
+
return; // 读不出 holder:保守留下
|
|
139
|
+
const alive = workdirLockHolderAlive(holder.pid);
|
|
140
|
+
if (alive === false)
|
|
141
|
+
return await remove(lockPath); // holder 确定已死
|
|
142
|
+
if (alive !== true)
|
|
143
|
+
return; // 判不了:留下
|
|
144
|
+
// pid 复用陷阱:holder pid 此刻可能属于一个毫不相干的进程(本机实测 2026-10-27:一条测试残留锁的
|
|
145
|
+
// pid 9704 已被 `NoiseCancelingEngine.exe` 复用)。只有「它声明的工作目录确实不存在 + 锁已过
|
|
146
|
+
// staleMs」才清 —— 那说明 holder 早就不在这把锁上了,而不是正在干活。
|
|
147
|
+
const st = await fs.stat(lockPath).catch(() => null);
|
|
148
|
+
if (st === null || Date.now() - st.mtimeMs <= DEFAULT_WORKDIR_LOCK_STALE_MS)
|
|
149
|
+
return;
|
|
150
|
+
const workDir = typeof holder.workDir === 'string' ? holder.workDir : '';
|
|
151
|
+
if (workDir === '')
|
|
152
|
+
return;
|
|
153
|
+
if (await fs.stat(workDir).then(() => true, () => false))
|
|
154
|
+
return;
|
|
155
|
+
await remove(lockPath);
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
59
158
|
async function tryAcquire(lockPath, workDir, staleMs, now) {
|
|
60
159
|
const token = randomUUID();
|
|
61
160
|
try {
|
|
@@ -84,7 +183,18 @@ async function tryAcquire(lockPath, workDir, staleMs, now) {
|
|
|
84
183
|
const code = err.code;
|
|
85
184
|
if (code !== 'EEXIST')
|
|
86
185
|
throw err;
|
|
87
|
-
//
|
|
186
|
+
// ① holder pid 已经不存在 ⇒ 这把锁必然陈旧,立即回收,不等 staleMs(重启/崩溃后的现场)
|
|
187
|
+
const holder = await readLockHolder(lockPath);
|
|
188
|
+
if (workdirLockHolderAlive(holder?.pid) === false) {
|
|
189
|
+
try {
|
|
190
|
+
await fs.unlink(lockPath);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// 别人刚好也判定并回收、或 holder 的 finally 刚好跑到:下一轮重试即可
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
// ② 读不出 holder / holder 仍存活 ⇒ 只有超过 staleMs 才视为崩溃残留(绝不抢正在跑的长任务)
|
|
88
198
|
try {
|
|
89
199
|
const st = await fs.stat(lockPath);
|
|
90
200
|
if (now() - st.mtimeMs > staleMs)
|
|
@@ -109,8 +219,15 @@ export async function withWorkdirLock(workDir, fn, options = {}) {
|
|
|
109
219
|
const now = options.now ?? Date.now;
|
|
110
220
|
const key = workdirLockKey(workDir);
|
|
111
221
|
const lockPath = workdirLockPath(workDir);
|
|
222
|
+
// 顺带清扫本机别的死 holder 残留(每进程一次,失败不影响本次取锁)
|
|
223
|
+
void pruneDeadWorkdirLocksOnce().catch(() => undefined);
|
|
224
|
+
// 计时必须在进入串行链**之前**取:链上排队同样属于「在等这把锁」。否则一个只读调用排在
|
|
225
|
+
// 上一个 300 s 的同步后面时,计时从轮到它那一刻才清零,超时上限形同失效(2026-10-27 现场)。
|
|
226
|
+
const started = now();
|
|
112
227
|
const run = async () => {
|
|
113
|
-
|
|
228
|
+
// 排队期间调用方已超时放弃:轮到它时直接失败,**不执行 fn**,免得被遗弃的请求稍后仍去驱动工作副本
|
|
229
|
+
if (now() - started >= timeoutMs)
|
|
230
|
+
throw new WorkdirLockError(workDir, now() - started);
|
|
114
231
|
// 先同步做一次尝试,再进入轮询:无竞争的常见情形零额外延迟
|
|
115
232
|
let release = await tryAcquire(lockPath, workDir, staleMs, now);
|
|
116
233
|
while (release === null) {
|
|
@@ -132,16 +249,27 @@ export async function withWorkdirLock(workDir, fn, options = {}) {
|
|
|
132
249
|
// 链里只保留「尾巴」的完成状态,异常不向下游传染
|
|
133
250
|
const tail = current.then(() => undefined, () => undefined);
|
|
134
251
|
chains.set(key, tail);
|
|
252
|
+
// 硬上界:timeoutMs 是「拿到锁」的上限,不是「开始计时后」的上限。只用 run 内的计时不够 ——
|
|
253
|
+
// 排在长任务后面的调用永远轮不到自己计时,界面就被历史任务拖着走。这里直接对链头 race,
|
|
254
|
+
// 超时 reject;被放弃的 run 之后还会被链唤醒,但入口的过期检查让它立刻抛(不碰工作副本)。
|
|
255
|
+
let rejectDeadline;
|
|
256
|
+
const deadline = new Promise((_, reject) => { rejectDeadline = reject; });
|
|
257
|
+
const timer = setTimeout(() => { rejectDeadline?.(new WorkdirLockError(workDir, timeoutMs)); }, timeoutMs);
|
|
258
|
+
// unref:这个计时器不该拖住进程退出(缺省 300 s)
|
|
259
|
+
if (typeof timer.unref === 'function')
|
|
260
|
+
timer.unref();
|
|
135
261
|
try {
|
|
136
|
-
return await current;
|
|
262
|
+
return await Promise.race([current, deadline]);
|
|
137
263
|
}
|
|
138
264
|
finally {
|
|
265
|
+
clearTimeout(timer);
|
|
139
266
|
if (chains.get(key) === tail)
|
|
140
267
|
chains.delete(key);
|
|
141
268
|
}
|
|
142
269
|
}
|
|
143
|
-
/**
|
|
270
|
+
/** 仅供测试:清空进程内串行链与一次性清扫标记(不影响磁盘上的锁文件) */
|
|
144
271
|
export function resetWorkdirLockChainsForTest() {
|
|
145
272
|
chains.clear();
|
|
273
|
+
pruneScheduled = false;
|
|
146
274
|
}
|
|
147
275
|
//# sourceMappingURL=workdir-lock.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-config-manager",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.86",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Backup, restore, export, import, migrate and sync your DeepSeek Harness (DSH) configuration: settings, plugins, MCP servers, skills, agent presets and workspaces. One-click migration to another machine. DSH 配置一键备份、恢复、导出、导入、迁移与同步插件。",
|
|
6
6
|
"icon": "./icon.svg",
|
package/src/index.ts
CHANGED
|
@@ -180,7 +180,7 @@ export const name = 'config-manager'
|
|
|
180
180
|
export const inject = ['settings', 'credentials']
|
|
181
181
|
|
|
182
182
|
/** Plugin version, kept in sync with package.json ("version"). */
|
|
183
|
-
export const PLUGIN_VERSION = '0.1.
|
|
183
|
+
export const PLUGIN_VERSION = '0.1.86'
|
|
184
184
|
|
|
185
185
|
/** Plugin own package name — excluded from its own exported plugins list. */
|
|
186
186
|
const PLUGIN_NAME = 'dsh-config-manager'
|
|
@@ -18,6 +18,7 @@ import { computeSnapshotMeta, DEFAULT_SYNC_TIMEOUT_MS, isEncryptedSections, Sync
|
|
|
18
18
|
import type { SyncSnapshot } from '../transport.ts';
|
|
19
19
|
import type { FilesSection, SectionData, SectionId } from '../../schema/types.ts';
|
|
20
20
|
import { sha256Hex } from '../../utils/hashing.ts';
|
|
21
|
+
import { resetWorkdirLockChainsForTest, workdirLockPath } from '../../utils/workdir-lock.ts';
|
|
21
22
|
|
|
22
23
|
/* ---------------- helpers ---------------- */
|
|
23
24
|
|
|
@@ -1411,4 +1412,35 @@ test('自检缓存:TTL 过期后重新自检', async (t) => {
|
|
|
1411
1412
|
assert.ok(calls.length > warm, 'TTL 过期后必须重新自检,绝不无限期返回旧事实');
|
|
1412
1413
|
});
|
|
1413
1414
|
|
|
1415
|
+
/**
|
|
1416
|
+
* 为什么有这条用例(真机 2026-10-27):自检挂在 `GET /sync/status` 上,而同步任务持锁是常态
|
|
1417
|
+
* (autosync 每 1~2 分钟一次 push)。此前它沿用工作副本锁缺省的 300 s 等待 ⇒ 同步页每次打开都白等
|
|
1418
|
+
* 满 10 s 接口预算再报超时。只读自检必须**有界**:拿不到锁立刻如实报「忙」,让调用方按可操作文案提示。
|
|
1419
|
+
*/
|
|
1420
|
+
test('只读自检有界:锁被活进程持有时报「工作副本忙」,绝不等满锁的缺省上限', async (t) => {
|
|
1421
|
+
clearInspectFactsCache();
|
|
1422
|
+
const dir = await makeGitWorkDir(t);
|
|
1423
|
+
const lockPath = workdirLockPath(dir);
|
|
1424
|
+
// 模拟另一个存活实例正在同步:pid 存活 ⇒ 不会被当作陈旧锁回收,只能等
|
|
1425
|
+
await fs.writeFile(lockPath, JSON.stringify({ pid: process.pid, token: 'other-instance' }));
|
|
1426
|
+
t.after(async () => {
|
|
1427
|
+
await fs.rm(lockPath, { force: true });
|
|
1428
|
+
resetWorkdirLockChainsForTest();
|
|
1429
|
+
});
|
|
1430
|
+
const calls: CallRecord[] = [];
|
|
1431
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
|
|
1432
|
+
|
|
1433
|
+
const t0 = Date.now();
|
|
1434
|
+
await assert.rejects(transport.inspectWorkDir(), (err: unknown) => {
|
|
1435
|
+
assert.ok(err instanceof GitTransportError, '必须是 GitTransportError');
|
|
1436
|
+
assert.equal(err.kind, 'conflict', '等锁超时是「通道忙」,不是协议/网络故障');
|
|
1437
|
+
assert.equal(err.retryable, true);
|
|
1438
|
+
assert.match(err.message, /正被另一个同步任务占用/);
|
|
1439
|
+
return true;
|
|
1440
|
+
});
|
|
1441
|
+
const elapsed = Date.now() - t0;
|
|
1442
|
+
assert.ok(elapsed < 3_000, '自检必须在短上限内失败(实测 ' + String(elapsed) + 'ms),绝不能等缺省的 300 s');
|
|
1443
|
+
assert.equal(calls.length, 0, '拿不到锁就不得 spawn 任何 git 命令');
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1414
1446
|
|
|
@@ -108,6 +108,16 @@ export interface GitWorkDirFacts {
|
|
|
108
108
|
const INSPECT_FACTS_TTL_MS = 10_000;
|
|
109
109
|
const inspectFactsCache = new Map<string, { at: number; detail: boolean; facts: GitWorkDirFacts }>();
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* 只读自检等锁的上限(2026-10-27)。
|
|
113
|
+
*
|
|
114
|
+
* 为什么和写路径不同:写操作本来就该等(一次 push 十几秒是正常的),但自检挂在
|
|
115
|
+
* `GET /sync/status` 上,同步任务持锁期间它等待是**纯损失** —— 界面要的只是「工作副本有没有跟随
|
|
116
|
+
* 当前地址」,拿不到锁就如实报忙,让请求有界返回(再退一步还有 10 s 的接口预算兜底)。此前它沿用
|
|
117
|
+
* 缺省的 300 s:一次自动同步持锁就能让同步页每次打开都白等 10 s(真机 2026-10-27)。
|
|
118
|
+
*/
|
|
119
|
+
const INSPECT_WORKDIR_LOCK_TIMEOUT_MS = 1_500;
|
|
120
|
+
|
|
111
121
|
function inspectFactsCacheKey(workDir: string, repoUrl: string): string {
|
|
112
122
|
return workDir + '\u0000' + repoUrl;
|
|
113
123
|
}
|
|
@@ -508,7 +518,7 @@ export class GitTransport implements SyncTransport {
|
|
|
508
518
|
*
|
|
509
519
|
* 可重入:内层(ensureRepo/pullFromRemote/adoptRemoteHistory 等)不重复取锁。
|
|
510
520
|
*/
|
|
511
|
-
private async withWorkdir<T>(fn: () => Promise<T>, opts: { readonly?: boolean } = {}): Promise<T> {
|
|
521
|
+
private async withWorkdir<T>(fn: () => Promise<T>, opts: { readonly?: boolean; lockTimeoutMs?: number } = {}): Promise<T> {
|
|
512
522
|
try {
|
|
513
523
|
if (this.workdirLockDepth > 0) {
|
|
514
524
|
this.workdirLockDepth += 1;
|
|
@@ -519,6 +529,8 @@ export class GitTransport implements SyncTransport {
|
|
|
519
529
|
}
|
|
520
530
|
}
|
|
521
531
|
try {
|
|
532
|
+
// 只读自检传短上限:它挂在状态接口上,等不起一次同步(见 INSPECT_WORKDIR_LOCK_TIMEOUT_MS);
|
|
533
|
+
// 写路径不传,沿用缺省的 300 s。
|
|
522
534
|
return await withWorkdirLock(this.o.workDir, async () => {
|
|
523
535
|
this.workdirLockDepth += 1;
|
|
524
536
|
try {
|
|
@@ -526,7 +538,7 @@ export class GitTransport implements SyncTransport {
|
|
|
526
538
|
} finally {
|
|
527
539
|
this.workdirLockDepth -= 1;
|
|
528
540
|
}
|
|
529
|
-
});
|
|
541
|
+
}, opts.lockTimeoutMs === undefined ? {} : { timeoutMs: opts.lockTimeoutMs });
|
|
530
542
|
} catch (err) {
|
|
531
543
|
// 等锁超时 = 别人正在同步(另一个 DSH 实例 / 另一个同步任务),不是通道故障:
|
|
532
544
|
// 给可操作文案并标记 retryable,别让它伪装成「git 命令失败」。
|
|
@@ -637,8 +649,12 @@ export class GitTransport implements SyncTransport {
|
|
|
637
649
|
if (hit !== undefined && Date.now() - hit.at < INSPECT_FACTS_TTL_MS && (hit.detail || !detail)) {
|
|
638
650
|
return { ...hit.facts };
|
|
639
651
|
}
|
|
640
|
-
// 只读命令也要排队:与并发的 fetch/checkout 交错会让 status/rev-list
|
|
641
|
-
|
|
652
|
+
// 只读命令也要排队:与并发的 fetch/checkout 交错会让 status/rev-list 读到半更新状态。
|
|
653
|
+
// 但排队必须有界(短上限):拿不到锁就报「工作副本忙」,绝不把状态接口拖进 300 s 的等待。
|
|
654
|
+
const facts = await this.withWorkdir(() => this.inspectWorkDirLocked(opts), {
|
|
655
|
+
readonly: true,
|
|
656
|
+
lockTimeoutMs: INSPECT_WORKDIR_LOCK_TIMEOUT_MS,
|
|
657
|
+
});
|
|
642
658
|
const prev = inspectFactsCache.get(key);
|
|
643
659
|
if (detail || prev === undefined || !prev.detail) {
|
|
644
660
|
inspectFactsCache.set(key, { at: Date.now(), detail, facts });
|
|
@@ -15,8 +15,10 @@ import path from 'node:path';
|
|
|
15
15
|
import {
|
|
16
16
|
DEFAULT_WORKDIR_LOCK_STALE_MS,
|
|
17
17
|
WorkdirLockError,
|
|
18
|
+
pruneDeadWorkdirLocksOnce,
|
|
18
19
|
resetWorkdirLockChainsForTest,
|
|
19
20
|
withWorkdirLock,
|
|
21
|
+
workdirLockHolderAlive,
|
|
20
22
|
workdirLockPath,
|
|
21
23
|
} from './workdir-lock.ts';
|
|
22
24
|
|
|
@@ -65,8 +67,9 @@ test('不同工作副本:互不阻塞(锁按目录隔离)', async (t) => {
|
|
|
65
67
|
|
|
66
68
|
test('等锁超时:别人持有锁且未过期 → 抛 WorkdirLockError(而不是无限等待)', async (t) => {
|
|
67
69
|
const dir = await makeDir(t);
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
+
// 模拟另一个**仍然存活**的进程持有锁(mtime 新鲜、pid 存在 ⇒ 两条回收路径都不该触发)。
|
|
71
|
+
// 必须用真实存活 pid:死 pid 的锁现在会立即回收(见下一条用例),不再进入等待。
|
|
72
|
+
await fs.writeFile(workdirLockPath(dir), JSON.stringify({ pid: process.pid, token: 'other-process' }));
|
|
70
73
|
await assert.rejects(
|
|
71
74
|
withWorkdirLock(dir, async () => 'never', { timeoutMs: 30, sleep: fastSleep }),
|
|
72
75
|
(err: unknown) => {
|
|
@@ -79,22 +82,92 @@ test('等锁超时:别人持有锁且未过期 → 抛 WorkdirLockError(而
|
|
|
79
82
|
await fs.access(workdirLockPath(dir));
|
|
80
83
|
});
|
|
81
84
|
|
|
82
|
-
test('
|
|
85
|
+
test('holder pid 已死:立即回收锁,不等 staleMs(DSH 重启/崩溃现场,2026-10-27)', async (t) => {
|
|
83
86
|
const dir = await makeDir(t);
|
|
84
87
|
const lockPath = workdirLockPath(dir);
|
|
85
|
-
|
|
88
|
+
// 取一个必然不存在的 pid,并先证明判定本身是对的,免得把环境差异伪装成通过
|
|
89
|
+
const deadPid = 4_000_000;
|
|
90
|
+
assert.equal(workdirLockHolderAlive(deadPid), false, '前提:该 pid 必须不存在(否则本用例无意义)');
|
|
91
|
+
await fs.writeFile(lockPath, JSON.stringify({ pid: deadPid, token: 'dead-holder' }));
|
|
92
|
+
const t0 = Date.now();
|
|
93
|
+
assert.equal(await withWorkdirLock(dir, async () => 'ok'), 'ok');
|
|
94
|
+
assert.ok(Date.now() - t0 < 500, '死 holder 的锁必须立即回收,绝不能等到 staleMs(15 分钟)');
|
|
95
|
+
await assert.rejects(fs.access(lockPath), '释放后锁文件必须消失');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('陈旧锁回收:holder 仍存活但锁文件超过 stale 阈值 → 自动接管', async (t) => {
|
|
99
|
+
const dir = await makeDir(t);
|
|
100
|
+
const lockPath = workdirLockPath(dir);
|
|
101
|
+
// pid 存活 ⇒ pid 路径不回收,只能走 mtime 兜底(覆盖 holder 卡死、永远不释放 finally 的情形)
|
|
102
|
+
await fs.writeFile(lockPath, JSON.stringify({ pid: process.pid, token: 'hung-holder' }));
|
|
86
103
|
const stale = new Date(Date.now() - DEFAULT_WORKDIR_LOCK_STALE_MS - 60_000);
|
|
87
104
|
await fs.utimes(lockPath, stale, stale);
|
|
88
105
|
assert.equal(await withWorkdirLock(dir, async () => 'ok'), 'ok');
|
|
89
106
|
await assert.rejects(fs.access(lockPath), '释放后锁文件必须消失');
|
|
90
107
|
});
|
|
91
108
|
|
|
109
|
+
test('读不出的 holder 一律退回 mtime 规则:新鲜锁仍须等待,不得盲抢', async (t) => {
|
|
110
|
+
const dir = await makeDir(t);
|
|
111
|
+
const lockPath = workdirLockPath(dir);
|
|
112
|
+
await fs.writeFile(lockPath, 'not-json'); // 半写入/旧格式:判不了 holder 是否存活
|
|
113
|
+
await assert.rejects(
|
|
114
|
+
withWorkdirLock(dir, async () => 'never', { timeoutMs: 30, sleep: fastSleep }),
|
|
115
|
+
(err: unknown) => err instanceof WorkdirLockError,
|
|
116
|
+
);
|
|
117
|
+
await fs.access(lockPath);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('链上排队也计入超时:排在长任务后面的调用按时失败,且绝不执行 fn', async (t) => {
|
|
121
|
+
const dir = await makeDir(t);
|
|
122
|
+
const slow = withWorkdirLock(dir, async () => {
|
|
123
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
124
|
+
return 'slow';
|
|
125
|
+
});
|
|
126
|
+
let quickRan = false;
|
|
127
|
+
const t0 = Date.now();
|
|
128
|
+
await assert.rejects(
|
|
129
|
+
withWorkdirLock(dir, async () => { quickRan = true; return 'quick'; }, { timeoutMs: 60 }),
|
|
130
|
+
(err: unknown) => err instanceof WorkdirLockError,
|
|
131
|
+
);
|
|
132
|
+
const elapsed = Date.now() - t0;
|
|
133
|
+
assert.ok(elapsed < 350, '排队到上限就必须失败,不能等前一个长任务跑完(实测 ' + String(elapsed) + 'ms)');
|
|
134
|
+
assert.equal(quickRan, false, '已超时放弃的调用绝不允许随后仍去驱动工作副本');
|
|
135
|
+
assert.equal(await slow, 'slow');
|
|
136
|
+
});
|
|
137
|
+
|
|
92
138
|
test('异常也释放锁:fn 抛错后下一次仍能拿到锁(不会把后续同步永久卡死)', async (t) => {
|
|
93
139
|
const dir = await makeDir(t);
|
|
94
140
|
await assert.rejects(withWorkdirLock(dir, async () => { throw new Error('boom'); }), /boom/);
|
|
95
141
|
assert.equal(await withWorkdirLock(dir, async () => 'ok'), 'ok');
|
|
96
142
|
});
|
|
97
143
|
|
|
144
|
+
test('一次性清扫:holder 已死的残留锁被删,活锁与读不出的锁一律保留', async (t) => {
|
|
145
|
+
const dir = await makeDir(t); // 顺便重置一次性清扫标记
|
|
146
|
+
const suffix = String(Date.now()) + String(Math.random()).slice(2, 8);
|
|
147
|
+
const deadPath = path.join(os.tmpdir(), 'dshcm-workdir-' + suffix + 'a.lock');
|
|
148
|
+
const alivePath = path.join(os.tmpdir(), 'dshcm-workdir-' + suffix + 'b.lock');
|
|
149
|
+
const opaquePath = path.join(os.tmpdir(), 'dshcm-workdir-' + suffix + 'c.lock');
|
|
150
|
+
// pid 复用陷阱:pid 现在还活着,但它声明的工作目录早已不存在、锁也过了 staleMs
|
|
151
|
+
const reusedPath = path.join(os.tmpdir(), 'dshcm-workdir-' + suffix + 'd.lock');
|
|
152
|
+
const goneDir = path.join(os.tmpdir(), 'dshcm-gone-' + suffix);
|
|
153
|
+
await fs.writeFile(deadPath, JSON.stringify({ pid: 4_000_000, token: 'dead', workDir: dir }));
|
|
154
|
+
await fs.writeFile(alivePath, JSON.stringify({ pid: process.pid, token: 'alive', workDir: dir }));
|
|
155
|
+
await fs.writeFile(opaquePath, 'not-json');
|
|
156
|
+
await fs.writeFile(reusedPath, JSON.stringify({ pid: process.pid, token: 'reused', workDir: goneDir }));
|
|
157
|
+
const old = new Date(Date.now() - DEFAULT_WORKDIR_LOCK_STALE_MS - 60_000);
|
|
158
|
+
await fs.utimes(reusedPath, old, old);
|
|
159
|
+
t.after(async () => {
|
|
160
|
+
await Promise.all([deadPath, alivePath, opaquePath, reusedPath].map((f) => fs.rm(f, { force: true })));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await pruneDeadWorkdirLocksOnce();
|
|
164
|
+
|
|
165
|
+
await assert.rejects(fs.access(deadPath), 'holder 已死的残留锁必须被清掉');
|
|
166
|
+
await fs.access(alivePath); // 活锁 + 工作目录还在 ⇒ 动不得
|
|
167
|
+
await fs.access(opaquePath); // 读不出 holder ⇒ 保守保留
|
|
168
|
+
await assert.rejects(fs.access(reusedPath), '工作目录已消失且过了 staleMs ⇒ pid 复用造成的僵尸锁应清掉');
|
|
169
|
+
});
|
|
170
|
+
|
|
98
171
|
test('锁按绝对路径归一:同一目录的不同写法共享一把锁', async (t) => {
|
|
99
172
|
const dir = await makeDir(t);
|
|
100
173
|
let active = 0;
|