dsh-config-manager 0.1.85 → 0.2.0
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/cli/web/page.js +2 -2
- package/lib/client.d.ts +86 -1
- package/lib/client.js +2365 -772
- package/lib/core/native-path-picker.d.ts +49 -0
- package/lib/core/native-path-picker.js +475 -0
- package/lib/core/path-browse.d.ts +42 -0
- package/lib/core/path-browse.js +134 -0
- package/lib/foreign/claude-sessions.d.ts +1 -1
- package/lib/foreign/claude-sessions.js +1 -1
- package/lib/index.d.ts +2 -2
- package/lib/index.js +3 -3
- package/lib/market/security.js +1 -1
- package/lib/market/types.d.ts +1 -1
- package/lib/market/types.js +1 -1
- package/lib/routes/index.d.ts +1 -1
- package/lib/routes/index.js +2 -0
- package/lib/routes/kit.d.ts +2 -2
- package/lib/routes/path-browse.d.ts +3 -0
- package/lib/routes/path-browse.js +63 -0
- package/lib/security/secret-scanner.js +1 -1
- package/lib/sync/git/git-transport.js +18 -3
- package/lib/ui/sync-settings-view.d.ts +18 -0
- package/lib/ui/sync-settings-view.js +20 -0
- package/lib/utils/workdir-lock.d.ts +26 -2
- package/lib/utils/workdir-lock.js +133 -5
- package/package.json +2 -2
- package/src/cli/web/page.ts +2 -2
- package/src/client/ConfigManagerSection.tsx +20 -17
- package/src/client/api.ts +60 -1
- package/src/client/bundle-selfcontained.test.ts +9 -0
- package/src/client/chat/chat-entry-view.test.ts +1 -1
- package/src/client/chat/rollback-entry-view.test.ts +14 -0
- package/src/client/chat/rollback-entry-view.ts +2 -1
- package/src/client/common/CopyButton.tsx +21 -2
- package/src/client/common/Icon.tsx +7 -0
- package/src/client/common/routes.ts +5 -0
- package/src/client/config-manager.module.css +70 -0
- package/src/client/import/ImportWizardView.tsx +1 -0
- package/src/client/import/PathMappingForm.tsx +62 -8
- package/src/client/import/PathPickerDialog.tsx +172 -0
- package/src/client/import/dsh-directory-pick.test.ts +55 -0
- package/src/client/import/dsh-directory-pick.ts +55 -0
- package/src/client/import/import-wizard-steps.tsx +5 -2
- package/src/client/index.ts +12 -1
- package/src/client/locales.ts +25 -0
- package/src/client/lucide-icons.d.ts +2 -0
- package/src/client/sync/SyncPanel.tsx +4 -4
- package/src/client/sync/sync-view.ts +1 -1
- package/src/core/native-path-picker.test.ts +118 -0
- package/src/core/native-path-picker.ts +517 -0
- package/src/core/path-browse.test.ts +88 -0
- package/src/core/path-browse.ts +166 -0
- package/src/foreign/claude-sessions.ts +3 -3
- package/src/index.ts +3 -3
- package/src/market/security.ts +1 -1
- package/src/market/types.ts +1 -1
- package/src/routes/index.ts +3 -1
- package/src/routes/kit.ts +2 -2
- package/src/routes/path-browse.ts +70 -0
- package/src/security/secret-scanner.test.ts +1 -1
- package/src/security/secret-scanner.ts +1 -1
- package/src/sync/git/git-transport.test.ts +32 -0
- package/src/sync/git/git-transport.ts +20 -4
- package/src/ui/session-issue-labels.ts +1 -1
- package/src/ui/sync-settings-view.test.ts +18 -1
- package/src/ui/sync-settings-view.ts +21 -0
- package/src/utils/workdir-lock.test.ts +77 -4
- package/src/utils/workdir-lock.ts +122 -5
|
@@ -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;
|
|
@@ -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
|
*/
|
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
}
|