@zhushanwen/pi-file-lock 0.2.0 → 0.4.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/package.json +3 -3
- package/src/__tests__/file-lock-external-removal.test.ts +14 -14
- package/src/__tests__/file-lock.test.ts +33 -82
- package/src/__tests__/lock-core.test.ts +4 -4
- package/src/file-lock.ts +13 -95
- package/src/index.ts +2 -13
- package/src/lock-core.ts +4 -3
- package/src/__tests__/file-lock-backoff.test.ts +0 -130
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-file-lock",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Shared cross-process file lock for Pi extensions — zero-dependency self-implemented mkdir lock (protocol-compatible with proper-lockfile 4.1.2) with stale takeover and
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Shared sync cross-process file lock for Pi extensions — zero-dependency self-implemented mkdir lock (protocol-compatible with proper-lockfile 4.1.2) with stale takeover and busy-wait retry, aligned with the runtime-side lock protocol (shared library, not a Pi extension).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"exports": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"src/"
|
|
22
22
|
],
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@zhushanwen/pi-extension-logger": "0.
|
|
24
|
+
"@zhushanwen/pi-extension-logger": "0.6.0"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^24.0.0",
|
|
@@ -13,9 +13,9 @@ import * as path from "node:path";
|
|
|
13
13
|
|
|
14
14
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { withFileLockSync } from "../file-lock.ts";
|
|
17
17
|
|
|
18
|
-
describe("
|
|
18
|
+
describe("withFileLockSync 锁目录被外部删除(原 compromise 场景的自实现语义)", () => {
|
|
19
19
|
let tmpDir: string;
|
|
20
20
|
let target: string;
|
|
21
21
|
|
|
@@ -23,15 +23,15 @@ describe("withFileLock 锁目录被外部删除(原 compromise 场景的自实
|
|
|
23
23
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "file-lock-extrem-"));
|
|
24
24
|
target = path.join(tmpDir, "target.json");
|
|
25
25
|
});
|
|
26
|
-
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
|
26
|
+
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }));
|
|
27
27
|
|
|
28
|
-
it("fn 执行期间锁被外部删除 → fn 正常返回 + release 静默成功 + 可立即再锁",
|
|
29
|
-
const result =
|
|
28
|
+
it("fn 执行期间锁被外部删除 → fn 正常返回 + release 静默成功 + 可立即再锁", () => {
|
|
29
|
+
const result = withFileLockSync(
|
|
30
30
|
target,
|
|
31
|
-
|
|
31
|
+
() => {
|
|
32
32
|
// 模拟外部清理(对端 stale 夺取会先 rmdir 再 mkdir;此处直接删):
|
|
33
33
|
// 自实现无保活定时器,删除本身不触发任何回调
|
|
34
|
-
fs.rmSync(`${target}.lock`, { recursive: true, force: true });
|
|
34
|
+
fs.rmSync(`${target}.lock`, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
35
35
|
return "fn-done";
|
|
36
36
|
},
|
|
37
37
|
{ staleMs: 2000 },
|
|
@@ -40,17 +40,17 @@ describe("withFileLock 锁目录被外部删除(原 compromise 场景的自实
|
|
|
40
40
|
// fn 结果原样返回(无 compromised 拦截——该机制随保活一并移除)
|
|
41
41
|
expect(result).toBe("fn-done");
|
|
42
42
|
// release 对已消失的锁目录静默成功(ENOENT 容忍)——由下一断言间接证明:
|
|
43
|
-
// 若 release 抛错,
|
|
44
|
-
|
|
43
|
+
// 若 release 抛错,sync 版 finally 不吞错会直接外抛;直接再锁验证锁已释放
|
|
44
|
+
expect(withFileLockSync(target, () => "again")).toBe("again");
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
it("fn 抛错且锁目录已被外部删除 → 错误照常外抛 + release 不叠加失败",
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
fs.rmSync(`${target}.lock`, { recursive: true, force: true });
|
|
47
|
+
it("fn 抛错且锁目录已被外部删除 → 错误照常外抛 + release 不叠加失败", () => {
|
|
48
|
+
expect(() =>
|
|
49
|
+
withFileLockSync(target, () => {
|
|
50
|
+
fs.rmSync(`${target}.lock`, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
51
51
|
throw new Error("boom");
|
|
52
52
|
}),
|
|
53
|
-
).
|
|
53
|
+
).toThrow("boom");
|
|
54
54
|
// finally 的 release 对 ENOENT 静默——不遮蔽原始 boom 错误(上方断言已过)
|
|
55
55
|
});
|
|
56
56
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/__tests__/file-lock.test.ts
|
|
2
2
|
//
|
|
3
3
|
// 跨进程文件锁单测(真实文件系统,不 mock fs):
|
|
4
|
-
// -
|
|
4
|
+
// - sync 版临界区互斥(并发不交错)
|
|
5
5
|
// - unlock 后可再锁(finally 释放语义)
|
|
6
6
|
// - sync 版 fail-fast(ELOCKED 预算耗尽抛错,不用默认 1s——测试覆盖盖短预算)
|
|
7
7
|
// - 真实跨进程互斥:两个 node 子进程并发 RMW 同一 JSON 文件,计数零丢失
|
|
@@ -15,7 +15,7 @@ import { fileURLToPath } from "node:url";
|
|
|
15
15
|
|
|
16
16
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import { withFileLockSync } from "../file-lock.ts";
|
|
19
19
|
|
|
20
20
|
// 本文件全部用例都是真实文件系统 IO(跨进程锁、子进程 RMW),CI 慢盘上单次用例
|
|
21
21
|
// 可达 7s+,统一放宽文件级预算(vitest 默认 5s)——断言强度不受影响
|
|
@@ -23,73 +23,6 @@ vi.setConfig({ testTimeout: 20000 });
|
|
|
23
23
|
|
|
24
24
|
const PKG_DIR = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
|
|
25
25
|
|
|
26
|
-
describe("withFileLock (async)", () => {
|
|
27
|
-
let tmpDir: string;
|
|
28
|
-
let target: string;
|
|
29
|
-
|
|
30
|
-
beforeEach(() => {
|
|
31
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "file-lock-test-"));
|
|
32
|
-
target = path.join(tmpDir, "target.json");
|
|
33
|
-
});
|
|
34
|
-
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
|
35
|
-
|
|
36
|
-
it("并发临界区互斥:计数无交错丢失", async () => {
|
|
37
|
-
let counter = 0;
|
|
38
|
-
const inside: number[] = [];
|
|
39
|
-
const tasks = Array.from({ length: 20 }, () =>
|
|
40
|
-
withFileLock(target, async () => {
|
|
41
|
-
counter += 1;
|
|
42
|
-
inside.push(counter);
|
|
43
|
-
// 让出 event loop 制造无锁时必交错的窗口
|
|
44
|
-
await new Promise((r) => setTimeout(r, 1));
|
|
45
|
-
}),
|
|
46
|
-
);
|
|
47
|
-
await Promise.all(tasks);
|
|
48
|
-
expect(counter).toBe(20);
|
|
49
|
-
// 每个临界区进入时的 counter 单调 +1(无两个临界区读到同值)
|
|
50
|
-
expect(new Set(inside).size).toBe(20);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("fn 抛错也释放锁(finally 语义:后续可再锁)", async () => {
|
|
54
|
-
await expect(
|
|
55
|
-
withFileLock(target, async () => {
|
|
56
|
-
throw new Error("boom");
|
|
57
|
-
}),
|
|
58
|
-
).rejects.toThrow("boom");
|
|
59
|
-
// 同一 target 立即可再锁 = 前次已释放
|
|
60
|
-
await expect(withFileLock(target, async () => "ok")).resolves.toBe("ok");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("嵌套取锁 → ELOCKED(retries:0 首试失败即抛,带 code;外层 finally 释放后可再锁)", async () => {
|
|
64
|
-
await expect(
|
|
65
|
-
withFileLock(
|
|
66
|
-
target,
|
|
67
|
-
() =>
|
|
68
|
-
withFileLock(target, async () => "never", {
|
|
69
|
-
retries: 0,
|
|
70
|
-
staleMs: 60_000, // stale 远大于临界区,锁不会被夺取
|
|
71
|
-
}),
|
|
72
|
-
),
|
|
73
|
-
).rejects.toMatchObject({ code: "ELOCKED" });
|
|
74
|
-
// 外层 finally 已释放 → 同一 target 立即可再锁
|
|
75
|
-
await expect(withFileLock(target, async () => "ok", { retries: 0 })).resolves.toBe("ok");
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("锁内 RMW:并发各 +1 一百次,文件终值 100(丢更新=锁失效)", async () => {
|
|
79
|
-
fs.writeFileSync(target, JSON.stringify({ n: 0 }), "utf-8");
|
|
80
|
-
const bump = (): Promise<void> =>
|
|
81
|
-
withFileLock(target, async () => {
|
|
82
|
-
const cur = JSON.parse(fs.readFileSync(target, "utf-8")) as { n: number };
|
|
83
|
-
cur.n += 1;
|
|
84
|
-
fs.writeFileSync(target, JSON.stringify(cur), "utf-8");
|
|
85
|
-
});
|
|
86
|
-
await Promise.all(Array.from({ length: 100 }, bump));
|
|
87
|
-
expect((JSON.parse(fs.readFileSync(target, "utf-8")) as { n: number }).n).toBe(100);
|
|
88
|
-
// 100 次并发锁 RMW 是真实文件系统 IO 密集测试,CI 慢盘上逼近 vitest 默认 5s,
|
|
89
|
-
// 放宽时间预算不改变断言强度(终值必须精确 100)
|
|
90
|
-
}, 20000);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
26
|
describe("withFileLockSync", () => {
|
|
94
27
|
let tmpDir: string;
|
|
95
28
|
let target: string;
|
|
@@ -98,7 +31,7 @@ describe("withFileLockSync", () => {
|
|
|
98
31
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "file-lock-sync-test-"));
|
|
99
32
|
target = path.join(tmpDir, "target.json");
|
|
100
33
|
});
|
|
101
|
-
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
|
34
|
+
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }));
|
|
102
35
|
|
|
103
36
|
it("返回 fn 结果且锁已释放(可立即再锁)", () => {
|
|
104
37
|
expect(withFileLockSync(target, () => 42)).toBe(42);
|
|
@@ -136,19 +69,35 @@ describe("真实跨进程互斥(D5a/D1e 验收形态)", () => {
|
|
|
136
69
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "file-lock-xproc-"));
|
|
137
70
|
const target = path.join(tmpDir, "shared.json");
|
|
138
71
|
fs.writeFileSync(target, JSON.stringify({ n: 0 }), "utf-8");
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
72
|
+
try {
|
|
73
|
+
// 子进程脚本:--experimental-strip-types 直接跑 TS 源码(Node >= 22.6),
|
|
74
|
+
// 循环 50 次锁内读-改-写。exitCode 非 0 = 子进程自身失败(锁/IO 异常)。
|
|
75
|
+
// 锁获取形态:withFileLockSync 传 retryBudgetMs:0(单次 fail-fast)+ 外层
|
|
76
|
+
// ELOCKED 固定 20ms 间隔重试自旋、30s 预算(对齐 runtime 侧
|
|
77
|
+
// pi-settings-store.test.ts 的 acquireLikePi 修复形态)——满载下临界区持有
|
|
78
|
+
// 窗口可能被 OS 抢占拉长,默认 fail-fast 预算会被偶发耗尽致子进程非零退出;
|
|
79
|
+
// 重试预算保证合理时间内必能拿到锁,互斥语义(lock-core 层)不受等待形态影响。
|
|
80
|
+
const worker = `
|
|
143
81
|
import * as fs from "node:fs";
|
|
144
|
-
import {
|
|
82
|
+
import { withFileLockSync } from "${PKG_DIR}/src/file-lock.ts";
|
|
145
83
|
const target = process.argv[2];
|
|
84
|
+
async function lockedRmw() {
|
|
85
|
+
const deadline = Date.now() + 30_000;
|
|
86
|
+
for (;;) {
|
|
87
|
+
try {
|
|
88
|
+
return withFileLockSync(target, () => {
|
|
89
|
+
const cur = JSON.parse(fs.readFileSync(target, "utf-8"));
|
|
90
|
+
cur.n += 1;
|
|
91
|
+
fs.writeFileSync(target, JSON.stringify(cur), "utf-8");
|
|
92
|
+
}, { retryBudgetMs: 0 });
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (!err || err.code !== "ELOCKED" || Date.now() >= deadline) throw err;
|
|
95
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
146
99
|
for (let i = 0; i < 50; i++) {
|
|
147
|
-
await
|
|
148
|
-
const cur = JSON.parse(fs.readFileSync(target, "utf-8"));
|
|
149
|
-
cur.n += 1;
|
|
150
|
-
fs.writeFileSync(target, JSON.stringify(cur), "utf-8");
|
|
151
|
-
});
|
|
100
|
+
await lockedRmw();
|
|
152
101
|
}
|
|
153
102
|
`;
|
|
154
103
|
// src 内相对 import 无 .ts 后缀(runtime tsc 无 allowImportingTsExtensions 的
|
|
@@ -189,7 +138,9 @@ register("./resolve-hook.mjs", import.meta.url);
|
|
|
189
138
|
}
|
|
190
139
|
expect((JSON.parse(fs.readFileSync(target, "utf-8")) as { n: number }).n).toBe(100);
|
|
191
140
|
} finally {
|
|
192
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
141
|
+
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
193
142
|
}
|
|
194
|
-
|
|
143
|
+
// 用例级预算须覆盖子进程 spawnSync timeout(60s,内含自旋重试预算 30s):
|
|
144
|
+
// 文件级 20s 会在子进程合法重试期间先红——预算放宽不改变断言强度(终值精确 100)
|
|
145
|
+
}, 90_000);
|
|
195
146
|
});
|
|
@@ -41,7 +41,7 @@ describe("acquireLock / release(async)", () => {
|
|
|
41
41
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lock-core-test-"));
|
|
42
42
|
target = path.join(tmpDir, "target.json");
|
|
43
43
|
});
|
|
44
|
-
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
|
44
|
+
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }));
|
|
45
45
|
|
|
46
46
|
it("acquire 创建 <目标>.lock 目录,release 删除", async () => {
|
|
47
47
|
const release = await acquireLock(target);
|
|
@@ -94,7 +94,7 @@ describe("acquireLock / release(async)", () => {
|
|
|
94
94
|
seedDeadLock(target, 1_500);
|
|
95
95
|
await expect(acquireLock(target, { staleMs: 50 })).rejects.toMatchObject({ code: "ELOCKED" });
|
|
96
96
|
// 清理测试自造的锁目录(acquire 未持有,afterEach 的 rmSync 亦可清,显式表达意图)
|
|
97
|
-
fs.rmSync(`${target}.lock`, { recursive: true, force: true });
|
|
97
|
+
fs.rmSync(`${target}.lock`, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
98
98
|
});
|
|
99
99
|
|
|
100
100
|
it("realpath:false:不存在的目标可锁;symlink 目标锁在 symlink 路径(不解析)", async () => {
|
|
@@ -137,7 +137,7 @@ describe("acquireLockSync", () => {
|
|
|
137
137
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lock-core-sync-test-"));
|
|
138
138
|
target = path.join(tmpDir, "target.json");
|
|
139
139
|
});
|
|
140
|
-
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
|
140
|
+
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }));
|
|
141
141
|
|
|
142
142
|
it("acquire/release 同构 async 版(目录创建与删除)", () => {
|
|
143
143
|
const release = acquireLockSync(target);
|
|
@@ -202,7 +202,7 @@ if (!fs.existsSync(target + ".lock")) {
|
|
|
202
202
|
// graceful exit 后锁目录不残留(等价 proper-lockfile signal-exit 清理语义)
|
|
203
203
|
expect(fs.existsSync(`${target}.lock`)).toBe(false);
|
|
204
204
|
} finally {
|
|
205
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
205
|
+
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
206
206
|
}
|
|
207
207
|
});
|
|
208
208
|
});
|
package/src/file-lock.ts
CHANGED
|
@@ -1,56 +1,36 @@
|
|
|
1
1
|
// src/file-lock.ts
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 跨进程同步文件锁(extension 侧共享 util,D5a/D1e,integrity-hardening.md §3.5)。
|
|
4
4
|
//
|
|
5
5
|
// 为什么存在:worktrees.json(多 pi 进程各一份扩展实例写)与 ext-config 家族
|
|
6
6
|
// (runtime + 扩展双写)都是跨进程 RMW——Node 单线程只能保证进程内不交错,
|
|
7
7
|
// 挡不住跨进程「后写者基于旧快照覆盖先写者」。
|
|
8
8
|
//
|
|
9
|
-
// 为什么是 async API 而非 runtime 侧的 withFileLockSync:扩展跑在 pi 子进程的
|
|
10
|
-
// async hook 上下文(session_start 等),同步 busy-wait 会阻塞整个 event loop;
|
|
11
|
-
// async 版用指数退避重试(本文件编排),sync 版保持同步 busy-wait。
|
|
12
|
-
//
|
|
13
9
|
// 锁原语:自实现 mkdir-lock(src/lock-core.ts,D1-A,零第三方依赖)。磁盘协议与
|
|
14
10
|
// pi 内嵌 proper-lockfile@4.1.2 逐字段兼容(协议细节与行为边界声明见 lock-core.ts
|
|
15
11
|
// 头注释——关键边界:无周期保活 touch,临界区必须远小于 stale)。本文件职责:
|
|
16
|
-
// 重试编排 + 对外 API + 诊断 logger 装配(logger 本体由包入口
|
|
17
|
-
//
|
|
12
|
+
// sync busy-wait 重试编排 + 对外 API + 诊断 logger 装配(logger 本体由包入口
|
|
13
|
+
// index.ts 注入,本文件不 import extension-logger——lock-core 的零依赖约束向上传导)。
|
|
18
14
|
//
|
|
19
15
|
// 锁协议(与 runtime 侧 packages/runtime/src/utils/file-lock.ts 对齐,登记表
|
|
20
16
|
// docs/architecture/data-source-registry.md §6):
|
|
21
17
|
// - lockfile 路径 = <目标文件>.lock(双方路径一致才互斥)
|
|
22
18
|
// - realpath:false —— 目标文件不存在也可锁;不解析 symlink
|
|
23
19
|
// - stale 30s:持锁进程崩溃后锁可被夺取(对齐 auth 惯例)
|
|
24
|
-
// - async 版退避重试:10 次重试 / factor 2 / 100ms~10s / randomize(公式与
|
|
25
|
-
// proper-lockfile 内部 retry 库一致,对齐 pi FileAuthStorageBackend.withLockAsync
|
|
26
|
-
// 及 runtime auth-storage.ts 范本;首试 + 10 重试 = 最多 11 次 acquire)
|
|
27
20
|
// - sync 版 busy-wait 重试(25ms / 预算 1s fail-fast):对齐 runtime 侧
|
|
28
21
|
// withFileLockSync;ext-config 家族的扩展侧写方(saveConfig)保持 sync 签名
|
|
29
22
|
// (调用链零波及),与 runtime 对端用同一把 lockfile 互斥
|
|
30
23
|
// - onCompromised 语义随保活 touch 一并移除(无保活则无 compromise 检测):
|
|
31
24
|
// 锁目录被外部删除时 release 静默成功(ENOENT 容忍,见 lock-core.ts removeLock)
|
|
32
25
|
//
|
|
33
|
-
// 契约:fn
|
|
34
|
-
//
|
|
35
|
-
// stale 30s——无保活 touch,超时持锁会被对端夺取)。
|
|
26
|
+
// 契约:fn 内仅做既定读改写(读目标文件 + 纯内存变更 + 原子写),禁其他 I/O /
|
|
27
|
+
// 再次对本文件加锁(嵌套取锁 ELOCKED → 重试耗尽 → 抛错),毫秒级完成(必须
|
|
28
|
+
// 远小于 stale 30s——无保活 touch,超时持锁会被对端夺取)。
|
|
36
29
|
|
|
37
|
-
import {
|
|
38
|
-
acquireLock,
|
|
39
|
-
acquireLockSync,
|
|
40
|
-
DEFAULT_STALE_MS,
|
|
41
|
-
type LockRelease,
|
|
42
|
-
} from "./lock-core";
|
|
30
|
+
import { acquireLockSync, DEFAULT_STALE_MS } from "./lock-core";
|
|
43
31
|
|
|
44
32
|
export { DEFAULT_STALE_MS };
|
|
45
33
|
|
|
46
|
-
/** 锁参数(默认值对齐 auth-storage.ts 范本;测试可覆盖以缩短等待)。 */
|
|
47
|
-
export interface FileLockOptions {
|
|
48
|
-
/** 锁 mtime 超过该值视为持锁者已死可夺取(stale 语义)。默认 30_000ms。 */
|
|
49
|
-
staleMs?: number;
|
|
50
|
-
/** retries 重试次数(首试之外)。默认 10。 */
|
|
51
|
-
retries?: number;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
34
|
/** sync 版锁参数(对齐 runtime withFileLockSync 默认值;测试可覆盖以缩短等待)。 */
|
|
55
35
|
export interface SyncFileLockOptions {
|
|
56
36
|
/** 锁 mtime 超过该值视为持锁者已死可夺取(stale 语义)。默认 30_000ms。 */
|
|
@@ -62,17 +42,11 @@ export interface SyncFileLockOptions {
|
|
|
62
42
|
}
|
|
63
43
|
|
|
64
44
|
// 默认锁参数(sync 版导出供对照测试断言与 runtime 侧 utils/file-lock.ts 默认值相等
|
|
65
|
-
//
|
|
66
|
-
|
|
45
|
+
// ——互斥由 lockfile 路径 + mkdir 原子协议保证,默认值对齐锚定的是两侧夺取时机
|
|
46
|
+
// (stale)与失败速度(重试参数)行为一致;runtime 侧 test/file-lock-parity.test.ts)
|
|
67
47
|
export const DEFAULT_RETRY_DELAY_MS = 25;
|
|
68
48
|
export const DEFAULT_RETRY_BUDGET_MS = 1_000;
|
|
69
49
|
|
|
70
|
-
// 退避参数(对齐 proper-lockfile 4.1.2 内部 retry 库的调用参数,即旧版
|
|
71
|
-
// retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 10_000, randomize: true })
|
|
72
|
-
const RETRY_FACTOR = 2;
|
|
73
|
-
const RETRY_MIN_TIMEOUT_MS = 100;
|
|
74
|
-
const RETRY_MAX_TIMEOUT_MS = 10_000;
|
|
75
|
-
|
|
76
50
|
// 诊断 logger:由包入口 index.ts 经 setFileLockLogger 注入(core 不沾 logger 依赖)。
|
|
77
51
|
// 默认 no-op——不经入口直接 import 本文件的测试/工具静默无日志。
|
|
78
52
|
let diagnosticsLogger: ((msg: string) => void) | undefined;
|
|
@@ -85,62 +59,14 @@ export function setFileLockLogger(log?: (msg: string) => void): void {
|
|
|
85
59
|
diagnosticsLogger = log;
|
|
86
60
|
}
|
|
87
61
|
|
|
88
|
-
/**
|
|
89
|
-
* 第 attempt 次尝试失败后的退避等待(attempt 从 0 计)。
|
|
90
|
-
* 公式照抄 proper-lockfile 内部 retry 库(retry.timeouts/createTimeout):
|
|
91
|
-
* randomize ? round((random()+1) * minTimeout * factor**attempt) capped maxTimeout
|
|
92
|
-
* 倍率区间 [1, 2),故第 attempt 次等待 ∈ [minTimeout*factor**attempt, 2*...),上限 10s。
|
|
93
|
-
*/
|
|
94
|
-
function backoffDelayMs(attempt: number): number {
|
|
95
|
-
const exponential = RETRY_MIN_TIMEOUT_MS * RETRY_FACTOR ** attempt;
|
|
96
|
-
return Math.min(Math.round((Math.random() + 1) * exponential), RETRY_MAX_TIMEOUT_MS);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* 跨进程文件锁内执行 async fn:拿不到锁时指数退避重试(100ms~10s/randomize,
|
|
101
|
-
* 首试 + retries 次 = 默认最多 11 次 acquire),重试耗尽抛 code:"ELOCKED" 错误
|
|
102
|
-
* (调用方决定降级路径);unlock 放 finally(fn 抛错也释放;锁目录已被外部删除时
|
|
103
|
-
* release 静默成功)。
|
|
104
|
-
*/
|
|
105
|
-
export async function withFileLock<T>(
|
|
106
|
-
filePath: string,
|
|
107
|
-
fn: () => Promise<T>,
|
|
108
|
-
opts?: FileLockOptions,
|
|
109
|
-
): Promise<T> {
|
|
110
|
-
const retries = opts?.retries ?? DEFAULT_RETRIES;
|
|
111
|
-
const staleMs = opts?.staleMs ?? DEFAULT_STALE_MS;
|
|
112
|
-
|
|
113
|
-
let release: LockRelease | undefined;
|
|
114
|
-
for (let attempt = 0; release === undefined; attempt++) {
|
|
115
|
-
try {
|
|
116
|
-
release = await acquireLock(filePath, { staleMs, log: diagnosticsLogger });
|
|
117
|
-
} catch (err) {
|
|
118
|
-
if (!isElocked(err)) throw err;
|
|
119
|
-
// 首试 + retries 次重试全部失败 → 抛 ELOCKED(对齐 retry 库 retries 次数语义)
|
|
120
|
-
if (attempt >= retries) throw err;
|
|
121
|
-
await sleep(backoffDelayMs(attempt));
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
try {
|
|
125
|
-
return await fn();
|
|
126
|
-
} finally {
|
|
127
|
-
try {
|
|
128
|
-
await release();
|
|
129
|
-
} catch (releaseErr) {
|
|
130
|
-
// release 仅在非 ENOENT 的 fs 错误(权限等)时失败——不外抛(fn 结果优先),
|
|
131
|
-
// 注入 logger 时留痕供排查
|
|
132
|
-
diagnosticsLogger?.(`[file-lock] release failed (ignorable): ${stringifyErr(releaseErr)}`);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
62
|
/**
|
|
138
63
|
* 同步跨进程文件锁内执行 fn:ELOCKED busy-wait 重试,预算耗尽抛带 ELOCKED code
|
|
139
64
|
* 的错误;unlock 放 finally(fn 抛错也释放)。
|
|
140
65
|
*
|
|
141
|
-
* 与
|
|
142
|
-
*
|
|
143
|
-
*
|
|
66
|
+
* 与 runtime 侧 sync 实现(packages/runtime/src/utils/file-lock.ts 的
|
|
67
|
+
* withFileLockSync)同协议——同一把 lockfile(<目标文件>.lock),互斥不依赖
|
|
68
|
+
* 实现归属。适用场景:调用链必须保持 sync 签名(如 llm-shared saveConfig,
|
|
69
|
+
* permission/rename-session 的命令回调零波及)。
|
|
144
70
|
* sleep 用 Atomics.wait(真 sleep 不烧 CPU,对齐 runtime 侧实现)。
|
|
145
71
|
*/
|
|
146
72
|
export function withFileLockSync<T>(
|
|
@@ -185,17 +111,9 @@ function isElocked(err: unknown): boolean {
|
|
|
185
111
|
return err instanceof Error && "code" in err && err.code === "ELOCKED";
|
|
186
112
|
}
|
|
187
113
|
|
|
188
|
-
function stringifyErr(err: unknown): string {
|
|
189
|
-
return err instanceof Error ? err.message : String(err);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
114
|
/** Atomics.wait 需要一个共享内存对象作等待目标;4 字节 = 一个 Int32 元素,仅占位不被写入。 */
|
|
193
115
|
const SLEEP_WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
|
|
194
116
|
|
|
195
117
|
function sleepSync(ms: number): void {
|
|
196
118
|
Atomics.wait(SLEEP_WAIT_BUFFER, 0, 0, ms);
|
|
197
119
|
}
|
|
198
|
-
|
|
199
|
-
function sleep(ms: number): Promise<void> {
|
|
200
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
201
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -7,20 +7,9 @@
|
|
|
7
7
|
|
|
8
8
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
setFileLockLogger,
|
|
12
|
-
withFileLock,
|
|
13
|
-
withFileLockSync,
|
|
14
|
-
type FileLockOptions,
|
|
15
|
-
type SyncFileLockOptions,
|
|
16
|
-
} from "./file-lock";
|
|
10
|
+
import { setFileLockLogger, withFileLockSync } from "./file-lock";
|
|
17
11
|
|
|
18
12
|
const logger = getLogger("file-lock");
|
|
19
13
|
setFileLockLogger((msg) => logger.debug(msg));
|
|
20
14
|
|
|
21
|
-
export {
|
|
22
|
-
withFileLock,
|
|
23
|
-
withFileLockSync,
|
|
24
|
-
type FileLockOptions,
|
|
25
|
-
type SyncFileLockOptions,
|
|
26
|
-
};
|
|
15
|
+
export { withFileLockSync };
|
package/src/lock-core.ts
CHANGED
|
@@ -49,8 +49,8 @@ export const DEFAULT_STALE_MS = 30_000;
|
|
|
49
49
|
/** stale 判死下限 clamp(照抄 proper-lockfile:`Math.max(options.stale || 0, 2000)`)。 */
|
|
50
50
|
const MIN_STALE_MS = 2_000;
|
|
51
51
|
|
|
52
|
-
/**
|
|
53
|
-
|
|
52
|
+
/** 锁原语选项(内部参数类型:重试/常量编排属消费方,runtime 传字面量不 import 本类型)。 */
|
|
53
|
+
interface LockCoreOptions {
|
|
54
54
|
/** 锁 mtime 超过该值视为持锁者已死可夺取。下限 clamp 2000ms。默认 DEFAULT_STALE_MS。 */
|
|
55
55
|
staleMs?: number;
|
|
56
56
|
/** 诊断日志注入(stale 夺取等关键分支)。core 自身零输出。 */
|
|
@@ -228,7 +228,8 @@ function acquireOnceSync(target: string, lockfilePath: string, staleMs: number,
|
|
|
228
228
|
|
|
229
229
|
/**
|
|
230
230
|
* 单次获取跨进程锁:成功返回 release(幂等);锁被他人持有且未 stale 时抛
|
|
231
|
-
* code:"ELOCKED"
|
|
231
|
+
* code:"ELOCKED" 错误(重试编排属消费方:extension 侧 sync busy-wait——
|
|
232
|
+
* src/file-lock.ts / runtime 侧 async 退避——packages/runtime/src/utils/file-lock.ts)。
|
|
232
233
|
*/
|
|
233
234
|
export async function acquireLock(filePath: string, opts?: LockCoreOptions): Promise<LockRelease> {
|
|
234
235
|
const target = resolveTarget(filePath);
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
// src/__tests__/file-lock-backoff.test.ts
|
|
2
|
-
//
|
|
3
|
-
// async 版退避重试参数单测(mock lock-core + fake timers,不碰真实文件系统)。
|
|
4
|
-
// 验收条款「async 退避参数 10×factor2 100ms~10s randomize」的精确断言:
|
|
5
|
-
// - 默认 retries=10 → 首试 + 10 次重试 = 11 次 acquire
|
|
6
|
-
// - 第 attempt 次失败后的等待 = min(round((random+1) * 100 * 2**attempt), 10_000)
|
|
7
|
-
// (照抄 proper-lockfile 内部 retry 库公式;randomize 倍率 [1,2))
|
|
8
|
-
// → 前 6 段区间 [100*2^i, 200*2^i],第 7 段起触顶 10s cap
|
|
9
|
-
// - staleMs 默认 30_000 透传 core;opts.retries 可覆盖次数(0 → 首试失败即抛)
|
|
10
|
-
|
|
11
|
-
import * as path from "node:path";
|
|
12
|
-
|
|
13
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
14
|
-
|
|
15
|
-
// mock 锁原语层:acquireLock 恒抛 ELOCKED(退避耗尽路径),调用时刻用 mock clock 记录
|
|
16
|
-
const { acquireLockMock } = vi.hoisted(() => ({ acquireLockMock: vi.fn() }));
|
|
17
|
-
|
|
18
|
-
vi.mock("../lock-core.ts", () => ({
|
|
19
|
-
DEFAULT_STALE_MS: 30_000,
|
|
20
|
-
acquireLock: acquireLockMock,
|
|
21
|
-
acquireLockSync: vi.fn(),
|
|
22
|
-
}));
|
|
23
|
-
|
|
24
|
-
import { DEFAULT_STALE_MS, withFileLock } from "../file-lock.ts";
|
|
25
|
-
|
|
26
|
-
function elocked(): Error {
|
|
27
|
-
return Object.assign(new Error("Lock file is already being held"), { code: "ELOCKED" });
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
describe("withFileLock 退避重试编排(对齐 retry 库 10×factor2 100ms~10s randomize)", () => {
|
|
31
|
-
const target = path.join("/tmp", "file-lock-backoff-fake", "target.json");
|
|
32
|
-
|
|
33
|
-
beforeEach(() => {
|
|
34
|
-
acquireLockMock.mockReset();
|
|
35
|
-
acquireLockMock.mockImplementation(async () => {
|
|
36
|
-
throw elocked();
|
|
37
|
-
});
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("默认参数:11 次 acquire(首试 + 10 重试),staleMs 30s 透传,最终抛 ELOCKED", async () => {
|
|
41
|
-
vi.useFakeTimers();
|
|
42
|
-
try {
|
|
43
|
-
const acquisitionTimes: number[] = [];
|
|
44
|
-
acquireLockMock.mockImplementation(async () => {
|
|
45
|
-
acquisitionTimes.push(Date.now());
|
|
46
|
-
throw elocked();
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
const pending = withFileLock(target, async () => "never");
|
|
50
|
-
// 立即 attach 断言(reject 可能先于 timers 推进发生,晚 attach 会报 unhandledRejection)
|
|
51
|
-
const settled = expect(pending).rejects.toMatchObject({ code: "ELOCKED" });
|
|
52
|
-
// flush 首试 microtask(调度第一个退避 timer),再一次性推进全部退避等待
|
|
53
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
54
|
-
await vi.advanceTimersByTimeAsync(1_000_000);
|
|
55
|
-
await settled;
|
|
56
|
-
|
|
57
|
-
expect(acquireLockMock).toHaveBeenCalledTimes(11);
|
|
58
|
-
// 透传参数:staleMs 默认 30_000,log 未注入时为 undefined
|
|
59
|
-
for (const call of acquireLockMock.mock.calls) {
|
|
60
|
-
expect(call[1]).toMatchObject({ staleMs: DEFAULT_STALE_MS });
|
|
61
|
-
}
|
|
62
|
-
expect(acquisitionTimes).toHaveLength(11);
|
|
63
|
-
} finally {
|
|
64
|
-
vi.useRealTimers();
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("退避序列:第 i 段等待 ∈ [100*2^i, 200*2^i](randomize),第 7 段起触顶 10s", async () => {
|
|
69
|
-
vi.useFakeTimers();
|
|
70
|
-
try {
|
|
71
|
-
const acquisitionTimes: number[] = [];
|
|
72
|
-
acquireLockMock.mockImplementation(async () => {
|
|
73
|
-
acquisitionTimes.push(Date.now());
|
|
74
|
-
throw elocked();
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
const pending = withFileLock(target, async () => "never");
|
|
78
|
-
const settled = expect(pending).rejects.toMatchObject({ code: "ELOCKED" });
|
|
79
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
80
|
-
await vi.advanceTimersByTimeAsync(1_000_000);
|
|
81
|
-
await settled;
|
|
82
|
-
|
|
83
|
-
expect(acquisitionTimes).toHaveLength(11);
|
|
84
|
-
const gaps = acquisitionTimes.slice(1).map((t, i) => t - acquisitionTimes[i]!);
|
|
85
|
-
expect(gaps).toHaveLength(10);
|
|
86
|
-
for (let i = 0; i < gaps.length; i++) {
|
|
87
|
-
// 第 i 段等待 ∈ [min(100*2^i, cap), min(200*2^i, cap)]:randomize 倍率 [1,2),
|
|
88
|
-
// i>=7 底数超 cap → 区间坍缩为 10000
|
|
89
|
-
const floor = Math.min(100 * 2 ** i, 10_000);
|
|
90
|
-
const ceiling = Math.min(200 * 2 ** i, 10_000);
|
|
91
|
-
expect(gaps[i]).toBeGreaterThanOrEqual(floor);
|
|
92
|
-
expect(gaps[i]).toBeLessThanOrEqual(ceiling);
|
|
93
|
-
}
|
|
94
|
-
// i=6 区间 [6400,12800) ∩ cap → [6400,10000](不必然触顶);i>=7 底数已超 cap,必然 10000
|
|
95
|
-
for (let i = 7; i < gaps.length; i++) {
|
|
96
|
-
expect(gaps[i]).toBe(10_000);
|
|
97
|
-
}
|
|
98
|
-
} finally {
|
|
99
|
-
vi.useRealTimers();
|
|
100
|
-
}
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("opts.retries: 0 → 仅首试 1 次即抛 ELOCKED(次数可覆盖)", async () => {
|
|
104
|
-
vi.useFakeTimers();
|
|
105
|
-
try {
|
|
106
|
-
const pending = withFileLock(target, async () => "never", { retries: 0 });
|
|
107
|
-
const settled = expect(pending).rejects.toMatchObject({ code: "ELOCKED" });
|
|
108
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
109
|
-
await settled;
|
|
110
|
-
expect(acquireLockMock).toHaveBeenCalledTimes(1);
|
|
111
|
-
} finally {
|
|
112
|
-
vi.useRealTimers();
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("非 ELOCKED 错误不重试,立即透传", async () => {
|
|
117
|
-
acquireLockMock.mockImplementation(async () => {
|
|
118
|
-
throw new Error("EPERM-ish");
|
|
119
|
-
});
|
|
120
|
-
await expect(withFileLock(target, async () => "never")).rejects.toThrow("EPERM-ish");
|
|
121
|
-
expect(acquireLockMock).toHaveBeenCalledTimes(1);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it("获取成功后 fn 结果返回、release 被调用", async () => {
|
|
125
|
-
const release = vi.fn(async () => {});
|
|
126
|
-
acquireLockMock.mockImplementation(async () => release);
|
|
127
|
-
await expect(withFileLock(target, async () => "ok")).resolves.toBe("ok");
|
|
128
|
-
expect(release).toHaveBeenCalledTimes(1);
|
|
129
|
-
});
|
|
130
|
-
});
|