@zhushanwen/pi-file-lock 0.1.1 → 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/package.json +8 -5
- package/src/__tests__/file-lock-backoff.test.ts +130 -0
- package/src/__tests__/file-lock-external-removal.test.ts +56 -0
- package/src/__tests__/file-lock.test.ts +54 -8
- package/src/__tests__/lock-core.test.ts +208 -0
- package/src/file-lock.ts +90 -58
- package/src/index.ts +21 -1
- package/src/lock-core.ts +260 -0
- package/index.ts +0 -7
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-file-lock",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Shared
|
|
3
|
+
"version": "0.2.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 exponential-backoff retries, aligned with the runtime-side lock protocol (shared library, not a Pi extension).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./core": "./src/lock-core.ts"
|
|
10
|
+
},
|
|
7
11
|
"keywords": [
|
|
8
12
|
"pi-package",
|
|
9
13
|
"pi",
|
|
@@ -14,11 +18,10 @@
|
|
|
14
18
|
],
|
|
15
19
|
"license": "MIT",
|
|
16
20
|
"files": [
|
|
17
|
-
"src/"
|
|
18
|
-
"index.ts"
|
|
21
|
+
"src/"
|
|
19
22
|
],
|
|
20
23
|
"dependencies": {
|
|
21
|
-
"
|
|
24
|
+
"@zhushanwen/pi-extension-logger": "0.4.0"
|
|
22
25
|
},
|
|
23
26
|
"devDependencies": {
|
|
24
27
|
"@types/node": "^24.0.0",
|
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// src/__tests__/file-lock-external-removal.test.ts
|
|
2
|
+
//
|
|
3
|
+
// 锁目录被外部删除的容忍路径单测(真实文件系统)。前身 file-lock-compromise.test.ts
|
|
4
|
+
// (proper-lockfile onCompromised:保活定时器 stat 发现锁被删 → ERELEASED → debug 留痕)。
|
|
5
|
+
// D1-A 自实现无保活 touch(边界声明见 lock-core.ts 头注释),compromise 检测不存在:
|
|
6
|
+
// - fn 执行期间锁目录被外部删除 → 无定时器发现,fn 正常返回
|
|
7
|
+
// - release 时 rmdir 命中 ENOENT → 静默成功(照抄 proper-lockfile removeLock 容忍),
|
|
8
|
+
// 不外抛、不影响 fn 结果;同目标随后可立即再锁(目录确已消失,非残留态)
|
|
9
|
+
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
15
|
+
|
|
16
|
+
import { withFileLock } from "../file-lock.ts";
|
|
17
|
+
|
|
18
|
+
describe("withFileLock 锁目录被外部删除(原 compromise 场景的自实现语义)", () => {
|
|
19
|
+
let tmpDir: string;
|
|
20
|
+
let target: string;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "file-lock-extrem-"));
|
|
24
|
+
target = path.join(tmpDir, "target.json");
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
|
27
|
+
|
|
28
|
+
it("fn 执行期间锁被外部删除 → fn 正常返回 + release 静默成功 + 可立即再锁", async () => {
|
|
29
|
+
const result = await withFileLock(
|
|
30
|
+
target,
|
|
31
|
+
async () => {
|
|
32
|
+
// 模拟外部清理(对端 stale 夺取会先 rmdir 再 mkdir;此处直接删):
|
|
33
|
+
// 自实现无保活定时器,删除本身不触发任何回调
|
|
34
|
+
fs.rmSync(`${target}.lock`, { recursive: true, force: true });
|
|
35
|
+
return "fn-done";
|
|
36
|
+
},
|
|
37
|
+
{ staleMs: 2000 },
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// fn 结果原样返回(无 compromised 拦截——该机制随保活一并移除)
|
|
41
|
+
expect(result).toBe("fn-done");
|
|
42
|
+
// release 对已消失的锁目录静默成功(ENOENT 容忍)——由下一断言间接证明:
|
|
43
|
+
// 若 release 抛错,withFileLock 会吞错但此处再锁也必然成功;直接再锁验证锁已释放
|
|
44
|
+
await expect(withFileLock(target, async () => "again")).resolves.toBe("again");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("fn 抛错且锁目录已被外部删除 → 错误照常外抛 + release 不叠加失败", async () => {
|
|
48
|
+
await expect(
|
|
49
|
+
withFileLock(target, () => {
|
|
50
|
+
fs.rmSync(`${target}.lock`, { recursive: true, force: true });
|
|
51
|
+
throw new Error("boom");
|
|
52
|
+
}),
|
|
53
|
+
).rejects.toThrow("boom");
|
|
54
|
+
// finally 的 release 对 ENOENT 静默——不遮蔽原始 boom 错误(上方断言已过)
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -13,10 +13,14 @@ import * as os from "node:os";
|
|
|
13
13
|
import * as path from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
|
|
16
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
17
17
|
|
|
18
18
|
import { withFileLock, withFileLockSync } from "../file-lock.ts";
|
|
19
19
|
|
|
20
|
+
// 本文件全部用例都是真实文件系统 IO(跨进程锁、子进程 RMW),CI 慢盘上单次用例
|
|
21
|
+
// 可达 7s+,统一放宽文件级预算(vitest 默认 5s)——断言强度不受影响
|
|
22
|
+
vi.setConfig({ testTimeout: 20000 });
|
|
23
|
+
|
|
20
24
|
const PKG_DIR = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
|
|
21
25
|
|
|
22
26
|
describe("withFileLock (async)", () => {
|
|
@@ -56,6 +60,21 @@ describe("withFileLock (async)", () => {
|
|
|
56
60
|
await expect(withFileLock(target, async () => "ok")).resolves.toBe("ok");
|
|
57
61
|
});
|
|
58
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
|
+
|
|
59
78
|
it("锁内 RMW:并发各 +1 一百次,文件终值 100(丢更新=锁失效)", async () => {
|
|
60
79
|
fs.writeFileSync(target, JSON.stringify({ n: 0 }), "utf-8");
|
|
61
80
|
const bump = (): Promise<void> =>
|
|
@@ -66,7 +85,9 @@ describe("withFileLock (async)", () => {
|
|
|
66
85
|
});
|
|
67
86
|
await Promise.all(Array.from({ length: 100 }, bump));
|
|
68
87
|
expect((JSON.parse(fs.readFileSync(target, "utf-8")) as { n: number }).n).toBe(100);
|
|
69
|
-
|
|
88
|
+
// 100 次并发锁 RMW 是真实文件系统 IO 密集测试,CI 慢盘上逼近 vitest 默认 5s,
|
|
89
|
+
// 放宽时间预算不改变断言强度(终值必须精确 100)
|
|
90
|
+
}, 20000);
|
|
70
91
|
});
|
|
71
92
|
|
|
72
93
|
describe("withFileLockSync", () => {
|
|
@@ -116,7 +137,7 @@ describe("真实跨进程互斥(D5a/D1e 验收形态)", () => {
|
|
|
116
137
|
const target = path.join(tmpDir, "shared.json");
|
|
117
138
|
fs.writeFileSync(target, JSON.stringify({ n: 0 }), "utf-8");
|
|
118
139
|
try {
|
|
119
|
-
// 子进程脚本:--experimental-strip-types 直接跑 TS
|
|
140
|
+
// 子进程脚本:--experimental-strip-types 直接跑 TS 源码(Node >= 22.6),
|
|
120
141
|
// 循环 50 次锁内读-改-写。exitCode 非 0 = 子进程自身失败(锁/IO 异常)。
|
|
121
142
|
const worker = `
|
|
122
143
|
import * as fs from "node:fs";
|
|
@@ -130,16 +151,41 @@ for (let i = 0; i < 50; i++) {
|
|
|
130
151
|
});
|
|
131
152
|
}
|
|
132
153
|
`;
|
|
154
|
+
// src 内相对 import 无 .ts 后缀(runtime tsc 无 allowImportingTsExtensions 的
|
|
155
|
+
// 兼容形态,见 file-lock.ts 头注释),而 Node strip-types 的 ESM 严格扩展名解析
|
|
156
|
+
// 要求显式后缀——resolve 钩子在 ERR_MODULE_NOT_FOUND 时补 .ts,两个约束的交集。
|
|
157
|
+
// 纯 JS(钩子自身不经 strip-types),只对相对 specifier 生效。
|
|
158
|
+
const resolveHook = `
|
|
159
|
+
export async function resolve(specifier, context, next) {
|
|
160
|
+
if (specifier.startsWith("./") || specifier.startsWith("../")) {
|
|
161
|
+
try {
|
|
162
|
+
return await next(specifier, context);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (err && err.code === "ERR_MODULE_NOT_FOUND") return next(specifier + ".ts", context);
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return next(specifier, context);
|
|
169
|
+
}
|
|
170
|
+
`;
|
|
171
|
+
const registerScript = `
|
|
172
|
+
import { register } from "node:module";
|
|
173
|
+
register("./resolve-hook.mjs", import.meta.url);
|
|
174
|
+
`;
|
|
175
|
+
fs.writeFileSync(path.join(tmpDir, "resolve-hook.mjs"), resolveHook, "utf-8");
|
|
176
|
+
fs.writeFileSync(path.join(tmpDir, "register-hooks.mjs"), registerScript, "utf-8");
|
|
133
177
|
const workerFile = path.join(tmpDir, "worker.ts");
|
|
134
178
|
fs.writeFileSync(workerFile, worker, "utf-8");
|
|
135
179
|
const procs = [1, 2].map(() =>
|
|
136
|
-
spawnSync(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
180
|
+
spawnSync(
|
|
181
|
+
process.execPath,
|
|
182
|
+
["--experimental-strip-types", "--import", path.join(tmpDir, "register-hooks.mjs"), workerFile, target],
|
|
183
|
+
{ encoding: "utf-8", timeout: 60_000 },
|
|
184
|
+
),
|
|
140
185
|
);
|
|
141
186
|
for (const p of procs) {
|
|
142
|
-
|
|
187
|
+
// 失败消息带出 worker stderr/stdout,保证非零退出时调试信息不丢
|
|
188
|
+
expect(p.status, `worker stderr: ${p.stderr} stdout: ${p.stdout}`).toBe(0);
|
|
143
189
|
}
|
|
144
190
|
expect((JSON.parse(fs.readFileSync(target, "utf-8")) as { n: number }).n).toBe(100);
|
|
145
191
|
} finally {
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// src/__tests__/lock-core.test.ts
|
|
2
|
+
//
|
|
3
|
+
// lock-core 零依赖锁原语单测(真实文件系统,不 mock fs):
|
|
4
|
+
// - mkdir 上锁 / rmdir 释放(<目标>.lock 目录协议)
|
|
5
|
+
// - ELOCKED 错误码(async/sync 同构)
|
|
6
|
+
// - stale 判死夺取(先 rmdir 再 mkdir)+ stale 下限 clamp 2000ms
|
|
7
|
+
// - realpath:false 语义:不存在的目标可锁;symlink 目标锁在 symlink 路径(不解析)
|
|
8
|
+
// - graceful exit 兜底:子进程持锁正常退出后锁目录被 process.on('exit') 清理
|
|
9
|
+
//
|
|
10
|
+
// 协议参照:proper-lockfile@4.1.2 实装(pi 内嵌同款),逐字段兼容是 S3 互斥探针的前提。
|
|
11
|
+
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
19
|
+
|
|
20
|
+
import { acquireLock, acquireLockSync } from "../lock-core.ts";
|
|
21
|
+
|
|
22
|
+
// 真实文件系统 IO(含子进程),放宽文件级预算(vitest 默认 5s)
|
|
23
|
+
vi.setConfig({ testTimeout: 20000 });
|
|
24
|
+
|
|
25
|
+
const PKG_DIR = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
|
|
26
|
+
|
|
27
|
+
/** 手工构造一把「死亡进程遗留」的锁:mkdir + mtime 回拨 ageMs。 */
|
|
28
|
+
function seedDeadLock(target: string, ageMs: number): string {
|
|
29
|
+
const lockPath = `${target}.lock`;
|
|
30
|
+
fs.mkdirSync(lockPath, { recursive: true });
|
|
31
|
+
const past = new Date(Date.now() - ageMs);
|
|
32
|
+
fs.utimesSync(lockPath, past, past);
|
|
33
|
+
return lockPath;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("acquireLock / release(async)", () => {
|
|
37
|
+
let tmpDir: string;
|
|
38
|
+
let target: string;
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lock-core-test-"));
|
|
42
|
+
target = path.join(tmpDir, "target.json");
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
|
45
|
+
|
|
46
|
+
it("acquire 创建 <目标>.lock 目录,release 删除", async () => {
|
|
47
|
+
const release = await acquireLock(target);
|
|
48
|
+
const lockPath = `${target}.lock`;
|
|
49
|
+
expect(fs.existsSync(lockPath)).toBe(true);
|
|
50
|
+
expect(fs.statSync(lockPath).isDirectory()).toBe(true);
|
|
51
|
+
await release();
|
|
52
|
+
expect(fs.existsSync(lockPath)).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("release 幂等(二调 no-op 不抛错)", async () => {
|
|
56
|
+
const release = await acquireLock(target);
|
|
57
|
+
await release();
|
|
58
|
+
await expect(release()).resolves.toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("重复 acquire 同一目标 → ELOCKED(code + file 字段)", async () => {
|
|
62
|
+
const release = await acquireLock(target);
|
|
63
|
+
try {
|
|
64
|
+
const err = await acquireLock(target).catch((e: unknown) => e);
|
|
65
|
+
expect((err as { code?: string }).code).toBe("ELOCKED");
|
|
66
|
+
expect((err as { file?: string }).file).toBe(target);
|
|
67
|
+
expect((err as Error).message).toContain("Lock file is already being held");
|
|
68
|
+
} finally {
|
|
69
|
+
await release();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("stale 判死夺取:mtime 超龄 → 先 rmdir 再 mkdir,锁目录 mtime 刷新", async () => {
|
|
74
|
+
seedDeadLock(target, 31_000);
|
|
75
|
+
const logs: string[] = [];
|
|
76
|
+
const before = fs.statSync(`${target}.lock`).mtime.getTime();
|
|
77
|
+
const release = await acquireLock(target, { log: (m) => logs.push(m) });
|
|
78
|
+
try {
|
|
79
|
+
const lockPath = `${target}.lock`;
|
|
80
|
+
// 锁目录仍是目录(rmdir 后重新 mkdir),且 mtime 已刷新(夺取 = 新建)
|
|
81
|
+
expect(fs.statSync(lockPath).isDirectory()).toBe(true);
|
|
82
|
+
expect(fs.statSync(lockPath).mtime.getTime()).toBeGreaterThanOrEqual(before);
|
|
83
|
+
// 诊断日志注入可见(stale 夺取分支)
|
|
84
|
+
expect(logs.some((m) => m.includes("stale lock taken over"))).toBe(true);
|
|
85
|
+
} finally {
|
|
86
|
+
await release();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("stale 下限 clamp 2000ms(照抄 proper-lockfile):staleMs 50 实际按 2000 判死", async () => {
|
|
91
|
+
// mtime 老 1.5s(居中于 1s/2s 判定线之间,余量防 timing 抖动):staleMs 50 →
|
|
92
|
+
// clamp 为 2000 → 1500 < 2000 不判死 → ELOCKED。若实现漏掉 clamp(直接用 50),
|
|
93
|
+
// 50 < 1500 将错误夺取 → 本用例红。超龄夺取路径由「stale 判死夺取」用例覆盖
|
|
94
|
+
seedDeadLock(target, 1_500);
|
|
95
|
+
await expect(acquireLock(target, { staleMs: 50 })).rejects.toMatchObject({ code: "ELOCKED" });
|
|
96
|
+
// 清理测试自造的锁目录(acquire 未持有,afterEach 的 rmSync 亦可清,显式表达意图)
|
|
97
|
+
fs.rmSync(`${target}.lock`, { recursive: true, force: true });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("realpath:false:不存在的目标可锁;symlink 目标锁在 symlink 路径(不解析)", async () => {
|
|
101
|
+
// 目标文件不存在也可锁(realpath:true 时 ENOENT)
|
|
102
|
+
const release = await acquireLock(target);
|
|
103
|
+
expect(fs.existsSync(`${target}.lock`)).toBe(true);
|
|
104
|
+
await release();
|
|
105
|
+
|
|
106
|
+
// symlink 目标:锁必须落在 `${symlink}.lock`,不得解析到真实路径
|
|
107
|
+
const realTarget = path.join(tmpDir, "real.json");
|
|
108
|
+
fs.writeFileSync(realTarget, "{}", "utf-8");
|
|
109
|
+
const linkPath = path.join(tmpDir, "link.json");
|
|
110
|
+
fs.symlinkSync(realTarget, linkPath);
|
|
111
|
+
const releaseLink = await acquireLock(linkPath);
|
|
112
|
+
try {
|
|
113
|
+
expect(fs.existsSync(`${linkPath}.lock`)).toBe(true);
|
|
114
|
+
expect(fs.existsSync(`${realTarget}.lock`)).toBe(false);
|
|
115
|
+
} finally {
|
|
116
|
+
await releaseLink();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("路径规范化:path.resolve 归一化(.. 消除后同锁)", async () => {
|
|
121
|
+
const release = await acquireLock(path.join(tmpDir, "sub", "..", "target.json"));
|
|
122
|
+
try {
|
|
123
|
+
// sub/.. 归一化为 tmpDir 本身 → 锁在 `${tmpDir}/target.json.lock`
|
|
124
|
+
expect(fs.existsSync(`${path.join(tmpDir, "target.json")}.lock`)).toBe(true);
|
|
125
|
+
expect(fs.existsSync(`${path.join(tmpDir, "sub")}`)).toBe(false);
|
|
126
|
+
} finally {
|
|
127
|
+
await release();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("acquireLockSync", () => {
|
|
133
|
+
let tmpDir: string;
|
|
134
|
+
let target: string;
|
|
135
|
+
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lock-core-sync-test-"));
|
|
138
|
+
target = path.join(tmpDir, "target.json");
|
|
139
|
+
});
|
|
140
|
+
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
|
141
|
+
|
|
142
|
+
it("acquire/release 同构 async 版(目录创建与删除)", () => {
|
|
143
|
+
const release = acquireLockSync(target);
|
|
144
|
+
expect(fs.statSync(`${target}.lock`).isDirectory()).toBe(true);
|
|
145
|
+
release();
|
|
146
|
+
expect(fs.existsSync(`${target}.lock`)).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("重复 acquireSync → ELOCKED", () => {
|
|
150
|
+
const release = acquireLockSync(target);
|
|
151
|
+
try {
|
|
152
|
+
try {
|
|
153
|
+
acquireLockSync(target);
|
|
154
|
+
expect.unreachable("must throw");
|
|
155
|
+
} catch (err) {
|
|
156
|
+
expect((err as Error).message).toContain("Lock file is already being held");
|
|
157
|
+
expect((err as { code?: string }).code).toBe("ELOCKED");
|
|
158
|
+
}
|
|
159
|
+
} finally {
|
|
160
|
+
release();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("sync 版 stale 夺取", () => {
|
|
165
|
+
seedDeadLock(target, 31_000);
|
|
166
|
+
const logs: string[] = [];
|
|
167
|
+
const release = acquireLockSync(target, { log: (m) => logs.push(m) });
|
|
168
|
+
try {
|
|
169
|
+
expect(fs.statSync(`${target}.lock`).isDirectory()).toBe(true);
|
|
170
|
+
expect(logs.some((m) => m.includes("stale lock taken over"))).toBe(true);
|
|
171
|
+
} finally {
|
|
172
|
+
release();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("graceful exit 兜底(process.on('exit') 配对 rmdir)", () => {
|
|
178
|
+
it("子进程持锁正常退出后,锁目录被 exit hook 清理(无需等 stale)", () => {
|
|
179
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lock-core-exit-"));
|
|
180
|
+
const target = path.join(tmpDir, "target.json");
|
|
181
|
+
try {
|
|
182
|
+
// 子进程 acquire 后自然退出(graceful exit)——'exit' hook 必须清掉锁目录。
|
|
183
|
+
// 子进程内先断言锁已创建(证明「锁曾存在」),失败以非零退出码表达(不用
|
|
184
|
+
// console——extensions 日志规范禁 console;诊断信息由父进程断言消息带出)。
|
|
185
|
+
const worker = `
|
|
186
|
+
import * as fs from "node:fs";
|
|
187
|
+
import { acquireLock } from "${PKG_DIR}/src/lock-core.ts";
|
|
188
|
+
const target = process.argv[2];
|
|
189
|
+
await acquireLock(target);
|
|
190
|
+
if (!fs.existsSync(target + ".lock")) {
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
`;
|
|
194
|
+
const workerFile = path.join(tmpDir, "worker.ts");
|
|
195
|
+
fs.writeFileSync(workerFile, worker, "utf-8");
|
|
196
|
+
const proc = spawnSync(process.execPath, ["--experimental-strip-types", workerFile, target], {
|
|
197
|
+
encoding: "utf-8",
|
|
198
|
+
timeout: 30_000,
|
|
199
|
+
});
|
|
200
|
+
// 失败消息带出 worker stderr/stdout,保证非零退出时调试信息不丢
|
|
201
|
+
expect(proc.status, `worker stderr: ${proc.stderr} stdout: ${proc.stdout}`).toBe(0);
|
|
202
|
+
// graceful exit 后锁目录不残留(等价 proper-lockfile signal-exit 清理语义)
|
|
203
|
+
expect(fs.existsSync(`${target}.lock`)).toBe(false);
|
|
204
|
+
} finally {
|
|
205
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
});
|
package/src/file-lock.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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 单线程只能保证进程内不交错,
|
|
@@ -8,37 +8,46 @@
|
|
|
8
8
|
//
|
|
9
9
|
// 为什么是 async API 而非 runtime 侧的 withFileLockSync:扩展跑在 pi 子进程的
|
|
10
10
|
// async hook 上下文(session_start 等),同步 busy-wait 会阻塞整个 event loop;
|
|
11
|
-
//
|
|
12
|
-
//
|
|
11
|
+
// async 版用指数退避重试(本文件编排),sync 版保持同步 busy-wait。
|
|
12
|
+
//
|
|
13
|
+
// 锁原语:自实现 mkdir-lock(src/lock-core.ts,D1-A,零第三方依赖)。磁盘协议与
|
|
14
|
+
// pi 内嵌 proper-lockfile@4.1.2 逐字段兼容(协议细节与行为边界声明见 lock-core.ts
|
|
15
|
+
// 头注释——关键边界:无周期保活 touch,临界区必须远小于 stale)。本文件职责:
|
|
16
|
+
// 重试编排 + 对外 API + 诊断 logger 装配(logger 本体由包入口 index.ts 注入,
|
|
17
|
+
// 本文件不 import extension-logger——lock-core 的零依赖约束向上传导)。
|
|
13
18
|
//
|
|
14
19
|
// 锁协议(与 runtime 侧 packages/runtime/src/utils/file-lock.ts 对齐,登记表
|
|
15
20
|
// docs/architecture/data-source-registry.md §6):
|
|
16
|
-
// - lockfile 路径 = <目标文件>.lock
|
|
17
|
-
// - realpath:false ——
|
|
18
|
-
// 与 runtime 侧参数一致;锁前确保父目录存在
|
|
21
|
+
// - lockfile 路径 = <目标文件>.lock(双方路径一致才互斥)
|
|
22
|
+
// - realpath:false —— 目标文件不存在也可锁;不解析 symlink
|
|
19
23
|
// - stale 30s:持锁进程崩溃后锁可被夺取(对齐 auth 惯例)
|
|
20
|
-
// - async
|
|
21
|
-
// FileAuthStorageBackend.withLockAsync
|
|
24
|
+
// - async 版退避重试:10 次重试 / factor 2 / 100ms~10s / randomize(公式与
|
|
25
|
+
// proper-lockfile 内部 retry 库一致,对齐 pi FileAuthStorageBackend.withLockAsync
|
|
26
|
+
// 及 runtime auth-storage.ts 范本;首试 + 10 重试 = 最多 11 次 acquire)
|
|
22
27
|
// - sync 版 busy-wait 重试(25ms / 预算 1s fail-fast):对齐 runtime 侧
|
|
23
|
-
// withFileLockSync
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// 互斥保证的锁下写盘(对齐 pi throwIfCompromised 语义)
|
|
28
|
+
// withFileLockSync;ext-config 家族的扩展侧写方(saveConfig)保持 sync 签名
|
|
29
|
+
// (调用链零波及),与 runtime 对端用同一把 lockfile 互斥
|
|
30
|
+
// - onCompromised 语义随保活 touch 一并移除(无保活则无 compromise 检测):
|
|
31
|
+
// 锁目录被外部删除时 release 静默成功(ENOENT 容忍,见 lock-core.ts removeLock)
|
|
28
32
|
//
|
|
29
33
|
// 契约:fn 内禁止任何 await / 再次对本文件加锁(嵌套取锁 ELOCKED → 重试耗尽 →
|
|
30
|
-
// 抛错);持锁范围应为「读文件 + 纯内存变更 +
|
|
34
|
+
// 抛错);持锁范围应为「读文件 + 纯内存变更 + 原子写」,毫秒级(必须远小于
|
|
35
|
+
// stale 30s——无保活 touch,超时持锁会被对端夺取)。
|
|
31
36
|
|
|
32
|
-
import {
|
|
33
|
-
|
|
37
|
+
import {
|
|
38
|
+
acquireLock,
|
|
39
|
+
acquireLockSync,
|
|
40
|
+
DEFAULT_STALE_MS,
|
|
41
|
+
type LockRelease,
|
|
42
|
+
} from "./lock-core";
|
|
34
43
|
|
|
35
|
-
|
|
44
|
+
export { DEFAULT_STALE_MS };
|
|
36
45
|
|
|
37
46
|
/** 锁参数(默认值对齐 auth-storage.ts 范本;测试可覆盖以缩短等待)。 */
|
|
38
47
|
export interface FileLockOptions {
|
|
39
48
|
/** 锁 mtime 超过该值视为持锁者已死可夺取(stale 语义)。默认 30_000ms。 */
|
|
40
49
|
staleMs?: number;
|
|
41
|
-
/** retries
|
|
50
|
+
/** retries 重试次数(首试之外)。默认 10。 */
|
|
42
51
|
retries?: number;
|
|
43
52
|
}
|
|
44
53
|
|
|
@@ -54,62 +63,80 @@ export interface SyncFileLockOptions {
|
|
|
54
63
|
|
|
55
64
|
// 默认锁参数(sync 版导出供对照测试断言与 runtime 侧 utils/file-lock.ts 默认值相等
|
|
56
65
|
// ——两侧参数漂移会破坏「同一把锁」的互斥语义;runtime 侧 test/file-lock-parity.test.ts)
|
|
57
|
-
export const DEFAULT_STALE_MS = 30_000;
|
|
58
66
|
const DEFAULT_RETRIES = 10;
|
|
59
67
|
export const DEFAULT_RETRY_DELAY_MS = 25;
|
|
60
68
|
export const DEFAULT_RETRY_BUDGET_MS = 1_000;
|
|
61
69
|
|
|
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
|
+
// 诊断 logger:由包入口 index.ts 经 setFileLockLogger 注入(core 不沾 logger 依赖)。
|
|
77
|
+
// 默认 no-op——不经入口直接 import 本文件的测试/工具静默无日志。
|
|
78
|
+
let diagnosticsLogger: ((msg: string) => void) | undefined;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 注入诊断日志函数(包入口装配点;传 undefined 恢复 no-op)。
|
|
82
|
+
* 诊断内容:stale 夺取、release 失败等关键分支。
|
|
83
|
+
*/
|
|
84
|
+
export function setFileLockLogger(log?: (msg: string) => void): void {
|
|
85
|
+
diagnosticsLogger = log;
|
|
86
|
+
}
|
|
87
|
+
|
|
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
|
+
|
|
62
99
|
/**
|
|
63
|
-
* 跨进程文件锁内执行 async fn:拿不到锁时指数退避重试(100ms~10s/randomize
|
|
64
|
-
*
|
|
65
|
-
* unlock 放 finally(fn
|
|
100
|
+
* 跨进程文件锁内执行 async fn:拿不到锁时指数退避重试(100ms~10s/randomize,
|
|
101
|
+
* 首试 + retries 次 = 默认最多 11 次 acquire),重试耗尽抛 code:"ELOCKED" 错误
|
|
102
|
+
* (调用方决定降级路径);unlock 放 finally(fn 抛错也释放;锁目录已被外部删除时
|
|
103
|
+
* release 静默成功)。
|
|
66
104
|
*/
|
|
67
105
|
export async function withFileLock<T>(
|
|
68
106
|
filePath: string,
|
|
69
107
|
fn: () => Promise<T>,
|
|
70
108
|
opts?: FileLockOptions,
|
|
71
109
|
): Promise<T> {
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
randomize: true,
|
|
87
|
-
},
|
|
88
|
-
stale: opts?.staleMs ?? DEFAULT_STALE_MS,
|
|
89
|
-
onCompromised: (err: Error) => {
|
|
90
|
-
compromised = err;
|
|
91
|
-
},
|
|
92
|
-
});
|
|
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
|
+
}
|
|
93
124
|
try {
|
|
94
|
-
if (compromised) throw compromised;
|
|
95
125
|
return await fn();
|
|
96
126
|
} finally {
|
|
97
127
|
try {
|
|
98
128
|
await release();
|
|
99
|
-
} catch (
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
"[file-lock] unlock failed after compromise (ignorable):",
|
|
104
|
-
unlockErr instanceof Error ? unlockErr.message : String(unlockErr),
|
|
105
|
-
);
|
|
129
|
+
} catch (releaseErr) {
|
|
130
|
+
// release 仅在非 ENOENT 的 fs 错误(权限等)时失败——不外抛(fn 结果优先),
|
|
131
|
+
// 注入 logger 时留痕供排查
|
|
132
|
+
diagnosticsLogger?.(`[file-lock] release failed (ignorable): ${stringifyErr(releaseErr)}`);
|
|
106
133
|
}
|
|
107
134
|
}
|
|
108
135
|
}
|
|
109
136
|
|
|
110
137
|
/**
|
|
111
|
-
* 同步跨进程文件锁内执行 fn:
|
|
112
|
-
*
|
|
138
|
+
* 同步跨进程文件锁内执行 fn:ELOCKED busy-wait 重试,预算耗尽抛带 ELOCKED code
|
|
139
|
+
* 的错误;unlock 放 finally(fn 抛错也释放)。
|
|
113
140
|
*
|
|
114
141
|
* 与 async 版锁同一把 lockfile(<目标文件>.lock)——sync/async API 在磁盘上
|
|
115
142
|
* 是同一协议,互斥不依赖调用形态。适用场景:调用链必须保持 sync 签名
|
|
@@ -125,14 +152,11 @@ export function withFileLockSync<T>(
|
|
|
125
152
|
const retryDelayMs = opts?.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
|
126
153
|
const retryBudgetMs = opts?.retryBudgetMs ?? DEFAULT_RETRY_BUDGET_MS;
|
|
127
154
|
|
|
128
|
-
const dir = dirname(filePath);
|
|
129
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
130
|
-
|
|
131
155
|
const deadline = Date.now() + retryBudgetMs;
|
|
132
156
|
let release: (() => void) | undefined;
|
|
133
157
|
while (release === undefined) {
|
|
134
158
|
try {
|
|
135
|
-
release =
|
|
159
|
+
release = acquireLockSync(filePath, { staleMs, log: diagnosticsLogger });
|
|
136
160
|
} catch (err) {
|
|
137
161
|
if (!isElocked(err)) throw err;
|
|
138
162
|
if (Date.now() >= deadline) {
|
|
@@ -140,7 +164,7 @@ export function withFileLockSync<T>(
|
|
|
140
164
|
new Error(
|
|
141
165
|
`[file-lock] ${filePath} 写锁获取失败:ELOCKED 重试预算 ${retryBudgetMs}ms 耗尽` +
|
|
142
166
|
`(持锁方临界区异常或已崩溃,stale ${staleMs}ms 后可夺取)。恢复指引:稍后重试本次写入。`,
|
|
143
|
-
// cause 挂原始 ELOCKED
|
|
167
|
+
// cause 挂原始 ELOCKED 错误,保留锁原语诊断信息
|
|
144
168
|
{ cause: err },
|
|
145
169
|
),
|
|
146
170
|
{ code: "ELOCKED" },
|
|
@@ -161,9 +185,17 @@ function isElocked(err: unknown): boolean {
|
|
|
161
185
|
return err instanceof Error && "code" in err && err.code === "ELOCKED";
|
|
162
186
|
}
|
|
163
187
|
|
|
188
|
+
function stringifyErr(err: unknown): string {
|
|
189
|
+
return err instanceof Error ? err.message : String(err);
|
|
190
|
+
}
|
|
191
|
+
|
|
164
192
|
/** Atomics.wait 需要一个共享内存对象作等待目标;4 字节 = 一个 Int32 元素,仅占位不被写入。 */
|
|
165
193
|
const SLEEP_WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
|
|
166
194
|
|
|
167
195
|
function sleepSync(ms: number): void {
|
|
168
196
|
Atomics.wait(SLEEP_WAIT_BUFFER, 0, 0, ms);
|
|
169
197
|
}
|
|
198
|
+
|
|
199
|
+
function sleep(ms: number): Promise<void> {
|
|
200
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
201
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
//
|
|
3
|
+
// 包入口 + logger 装配点(D1-A:锁实现文件不 import extension-logger——
|
|
4
|
+
// lock-core 零依赖约束向上传导至本文件唯一的 logger import 处)。
|
|
5
|
+
// runtime 侧经 `@zhushanwen/pi-file-lock/core` 子入口直接用锁原语,不经本文件、
|
|
6
|
+
// 不拉 logger 依赖;extension 侧经本文件入口使用,logger 在此注入。
|
|
7
|
+
|
|
8
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
setFileLockLogger,
|
|
12
|
+
withFileLock,
|
|
13
|
+
withFileLockSync,
|
|
14
|
+
type FileLockOptions,
|
|
15
|
+
type SyncFileLockOptions,
|
|
16
|
+
} from "./file-lock";
|
|
17
|
+
|
|
18
|
+
const logger = getLogger("file-lock");
|
|
19
|
+
setFileLockLogger((msg) => logger.debug(msg));
|
|
20
|
+
|
|
1
21
|
export {
|
|
2
22
|
withFileLock,
|
|
3
23
|
withFileLockSync,
|
|
4
24
|
type FileLockOptions,
|
|
5
25
|
type SyncFileLockOptions,
|
|
6
|
-
}
|
|
26
|
+
};
|
package/src/lock-core.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// src/lock-core.ts
|
|
2
|
+
//
|
|
3
|
+
// 零依赖跨进程 mkdir 锁原语(D1-A 自实现,docs/design/file-lock-unification-and-reaper-sink.md §3.2)。
|
|
4
|
+
//
|
|
5
|
+
// 为什么自实现:包名入口原先包 proper-lockfile,但 runtime 经 tsup bundle 复用它时,
|
|
6
|
+
// 第三方对模块对象的内部操作(probe 精度缓存的 fs symbol)在 jiti 类加载器下失效——
|
|
7
|
+
// 本次事故根因。本文件只用 node:fs / node:path 内置调用实现同一磁盘协议,天然免疫
|
|
8
|
+
// 模块系统包装;runtime 经 `@zhushanwen/pi-file-lock/core` 子入口复用同一实现。
|
|
9
|
+
//
|
|
10
|
+
// 磁盘协议(逐字段照抄 proper-lockfile@4.1.2 实装 lib/lockfile.js——与 pi 内嵌版互斥
|
|
11
|
+
// 同一把锁的兼容性权威源;4.1.2 的 acquireLock/releaseLock 已内联在该文件):
|
|
12
|
+
// - 锁文件 = path.resolve(目标) + '.lock' 目录(mkdir 原子上锁)。realpath:false 分支:
|
|
13
|
+
// 仅字符串绝对化(path.resolve),不解析 symlink
|
|
14
|
+
// - mkdir EEXIST → stat 锁目录 mtime,`mtime < Date.now() - stale` 判死;stale 下限
|
|
15
|
+
// clamp 2000ms(照抄 `options.stale = Math.max(options.stale || 0, 2000)`)
|
|
16
|
+
// - stale → 先 rmdir 再 mkdir 夺取。两步间存在竞态窗口(他方同时夺取),与
|
|
17
|
+
// proper-lockfile 语义一致,接受
|
|
18
|
+
// - 判死检查中锁目录 ENOENT(他方刚释放/刚夺取)→ 以 stale:0 重试(跳过判死直接
|
|
19
|
+
// mkdir,失败即 ELOCKED),照抄防无限递归的结构
|
|
20
|
+
// - 释放 = rmdir,容忍 ENOENT(照抄 removeLock)
|
|
21
|
+
// - graceful exit 兜底:process.on('exit') 配对 rmdirSync,避免优雅退出后锁残留
|
|
22
|
+
// 要等 30s stale 才能夺取。覆盖范围边界:'exit' 只覆盖正常退出与 process.exit;
|
|
23
|
+
// 信号默认终止(SIGINT/SIGTERM 无 handler)场景 'exit' 不触发(proper-lockfile
|
|
24
|
+
// 经 signal-exit 额外覆盖该路径),锁残留回退 30s stale 夺取(同 SIGKILL 崩溃路径)
|
|
25
|
+
//
|
|
26
|
+
// 与 proper-lockfile 的行为边界(设计显式决策,非遗漏):
|
|
27
|
+
// - 不做周期 utimes 保活(proper-lockfile 的 updateLock 定时器):现有契约临界区
|
|
28
|
+
// 毫秒级,远小于 stale/2;持锁超过 stale 的进程视为已死可被夺取。锁 mtime 即
|
|
29
|
+
// mkdir 时刻,此后不变
|
|
30
|
+
// - 因此无 compromise 检测(保活定时器 stat/utimes 失败路径不存在);release 对
|
|
31
|
+
// 锁目录已被外部删除的场景静默成功(ENOENT 容忍)
|
|
32
|
+
// - 被夺取后 release 的二阶后果:本方持锁被对端 stale 夺取后,本方 release 的
|
|
33
|
+
// rmdir(及 exit-hook)会删除夺取者新建的锁目录——proper-lockfile 同场景由
|
|
34
|
+
// updateLock 发现 mtime 非 ours 而拒绝 unlock,自实现无保活故无此防线。
|
|
35
|
+
// 触发前提 = 临界区违约超 stale 30s(契约要求毫秒级,违约 30000 倍才可达),
|
|
36
|
+
// 风险接受(设计显式决策,此处显式声明)
|
|
37
|
+
//
|
|
38
|
+
// 零依赖约束(D1-A):本文件不得 import 任何项目内包或第三方包——extension-logger
|
|
39
|
+
// 携带 pi SDK peerDep 链,经 runtime re-export 会穿越 pi 边界。诊断日志走 opts.log
|
|
40
|
+
// 注入(包入口 index.ts 注入 extension-logger,runtime 不注入)。
|
|
41
|
+
|
|
42
|
+
import { mkdirSync, rmdirSync, statSync } from "node:fs";
|
|
43
|
+
import { mkdir, rmdir, stat } from "node:fs/promises";
|
|
44
|
+
import { dirname, resolve } from "node:path";
|
|
45
|
+
|
|
46
|
+
/** 锁 mtime 超过该值视为持锁者已死可夺取。默认 30_000ms(对齐 auth 惯例)。 */
|
|
47
|
+
export const DEFAULT_STALE_MS = 30_000;
|
|
48
|
+
|
|
49
|
+
/** stale 判死下限 clamp(照抄 proper-lockfile:`Math.max(options.stale || 0, 2000)`)。 */
|
|
50
|
+
const MIN_STALE_MS = 2_000;
|
|
51
|
+
|
|
52
|
+
/** 锁原语选项。 */
|
|
53
|
+
export interface LockCoreOptions {
|
|
54
|
+
/** 锁 mtime 超过该值视为持锁者已死可夺取。下限 clamp 2000ms。默认 DEFAULT_STALE_MS。 */
|
|
55
|
+
staleMs?: number;
|
|
56
|
+
/** 诊断日志注入(stale 夺取等关键分支)。core 自身零输出。 */
|
|
57
|
+
log?: (msg: string) => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** async release 函数:释放锁目录(幂等;锁目录已消失时静默成功)。 */
|
|
61
|
+
export type LockRelease = () => Promise<void>;
|
|
62
|
+
|
|
63
|
+
// ──────────────────────── 协议原语(照抄 lockfile.js 对应函数) ────────────────────────
|
|
64
|
+
|
|
65
|
+
/** realpath:false 分支:仅字符串绝对化,不解析 symlink(照抄 resolveCanonicalPath)。 */
|
|
66
|
+
function resolveTarget(filePath: string): string {
|
|
67
|
+
return resolve(filePath);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 照抄 getLockFile:`options.lockfilePath || \`${file}.lock\``(不自定义 lockfilePath)。 */
|
|
71
|
+
function lockPathOf(target: string): string {
|
|
72
|
+
return `${target}.lock`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 照抄 isLockStale:`stat.mtime.getTime() < Date.now() - stale`。 */
|
|
76
|
+
function isLockStale(mtimeMs: number, staleMs: number): boolean {
|
|
77
|
+
return mtimeMs < Date.now() - staleMs;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** ELOCKED 错误(message/fields 照抄 proper-lockfile)。 */
|
|
81
|
+
function elocked(target: string): Error {
|
|
82
|
+
return Object.assign(new Error("Lock file is already being held"), { code: "ELOCKED", file: target });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Node fs 错误 code 收窄(in 收窄,禁 as 断言——taste/no-unsafe-cast)。 */
|
|
86
|
+
function errCode(err: unknown): string | undefined {
|
|
87
|
+
return err instanceof Error && "code" in err && typeof err.code === "string" ? err.code : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 锁前确保父目录存在(mkdir lockfile 需要;recursive 对已存在目录静默成功)。 */
|
|
91
|
+
async function ensureParentDir(target: string): Promise<void> {
|
|
92
|
+
await mkdir(dirname(target), { recursive: true });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ──────────────────────── graceful exit 兜底(照抄 signal-exit 清理语义) ────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 当前进程持有的活跃锁(key = lockfilePath,value = 该锁的诊断 log 注入)。
|
|
99
|
+
* release/夺取移除时同步删表。
|
|
100
|
+
*/
|
|
101
|
+
const activeLocks = new Map<string, ((msg: string) => void) | undefined>();
|
|
102
|
+
|
|
103
|
+
let exitHookInstalled = false;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 进程退出点配对清理(模块级注册一次):exit handler 内只能同步操作,用 rmdirSync。
|
|
107
|
+
* 单把锁清理失败仅经注入 log 留痕、不阻断其余锁清理——进程已终止,无恢复动作
|
|
108
|
+
* (对齐 proper-lockfile onExit 的逐把 try/catch,可观测性略强)。
|
|
109
|
+
*/
|
|
110
|
+
function installExitHook(): void {
|
|
111
|
+
if (exitHookInstalled) return;
|
|
112
|
+
exitHookInstalled = true;
|
|
113
|
+
process.on("exit", () => {
|
|
114
|
+
for (const [lockfilePath, log] of activeLocks) {
|
|
115
|
+
try {
|
|
116
|
+
rmdirSync(lockfilePath);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
log?.(`[lock-core] exit cleanup failed for ${lockfilePath}: ${errCode(err) ?? String(err)}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
activeLocks.clear();
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 单次释放(rmdir,容忍 ENOENT——照抄 removeLock)。 */
|
|
126
|
+
async function removeLockAsync(lockfilePath: string): Promise<void> {
|
|
127
|
+
try {
|
|
128
|
+
await rmdir(lockfilePath);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (errCode(err) !== "ENOENT") throw err;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** removeLockAsync 的同步版。 */
|
|
135
|
+
function removeLockSync(lockfilePath: string): void {
|
|
136
|
+
try {
|
|
137
|
+
rmdirSync(lockfilePath);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (errCode(err) !== "ENOENT") throw err;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** release 闭包:幂等(二调 no-op);释放即出活跃锁表。 */
|
|
144
|
+
function makeRelease(lockfilePath: string): LockRelease {
|
|
145
|
+
let released = false;
|
|
146
|
+
return async () => {
|
|
147
|
+
if (released) return;
|
|
148
|
+
released = true;
|
|
149
|
+
activeLocks.delete(lockfilePath);
|
|
150
|
+
await removeLockAsync(lockfilePath);
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ──────────────────────── acquire ────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 照抄 acquireLock(async 版):mkdir 成功即获锁;EEXIST → stale 判死 → 夺取;
|
|
158
|
+
* stale<=0(重试轮)直接 ELOCKED,跳过判死防递归。
|
|
159
|
+
*/
|
|
160
|
+
async function acquireOnceAsync(target: string, lockfilePath: string, staleMs: number, log: ((msg: string) => void) | undefined): Promise<void> {
|
|
161
|
+
let acquired = false;
|
|
162
|
+
try {
|
|
163
|
+
await mkdir(lockfilePath);
|
|
164
|
+
acquired = true;
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (errCode(err) !== "EEXIST") throw err;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (acquired) {
|
|
170
|
+
activeLocks.set(lockfilePath, log);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 重试轮(stale:0):不再判死,失败即 ELOCKED(照抄 `options.stale <= 0` 分支)
|
|
175
|
+
if (staleMs <= 0) throw elocked(target);
|
|
176
|
+
|
|
177
|
+
let mtimeMs: number;
|
|
178
|
+
try {
|
|
179
|
+
mtimeMs = (await stat(lockfilePath)).mtime.getTime();
|
|
180
|
+
} catch (err) {
|
|
181
|
+
// 锁目录刚被释放/夺取:跳过判死直接重试(照抄 ENOENT → stale:0 重入,防递归)
|
|
182
|
+
if (errCode(err) === "ENOENT") return acquireOnceAsync(target, lockfilePath, 0, log);
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!isLockStale(mtimeMs, staleMs)) throw elocked(target);
|
|
187
|
+
|
|
188
|
+
log?.(`[lock-core] stale lock taken over: ${lockfilePath} (mtime age > ${staleMs}ms), removing and retrying`);
|
|
189
|
+
await removeLockAsync(lockfilePath);
|
|
190
|
+
return acquireOnceAsync(target, lockfilePath, 0, log);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* acquireLockSync(照抄 acquireLock 的 sync 语义,同构 acquireOnceAsync)。
|
|
195
|
+
*/
|
|
196
|
+
function acquireOnceSync(target: string, lockfilePath: string, staleMs: number, log: ((msg: string) => void) | undefined): void {
|
|
197
|
+
let acquired = false;
|
|
198
|
+
try {
|
|
199
|
+
mkdirSync(lockfilePath);
|
|
200
|
+
acquired = true;
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (errCode(err) !== "EEXIST") throw err;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (acquired) {
|
|
206
|
+
activeLocks.set(lockfilePath, log);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (staleMs <= 0) throw elocked(target);
|
|
211
|
+
|
|
212
|
+
let mtimeMs: number;
|
|
213
|
+
try {
|
|
214
|
+
mtimeMs = statSync(lockfilePath).mtime.getTime();
|
|
215
|
+
} catch (err) {
|
|
216
|
+
if (errCode(err) === "ENOENT") return acquireOnceSync(target, lockfilePath, 0, log);
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!isLockStale(mtimeMs, staleMs)) throw elocked(target);
|
|
221
|
+
|
|
222
|
+
log?.(`[lock-core] stale lock taken over: ${lockfilePath} (mtime age > ${staleMs}ms), removing and retrying`);
|
|
223
|
+
removeLockSync(lockfilePath);
|
|
224
|
+
return acquireOnceSync(target, lockfilePath, 0, log);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ──────────────────────── 对外 API ────────────────────────
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 单次获取跨进程锁:成功返回 release(幂等);锁被他人持有且未 stale 时抛
|
|
231
|
+
* code:"ELOCKED" 错误(重试编排属消费方:包入口 async 退避 / sync busy-wait)。
|
|
232
|
+
*/
|
|
233
|
+
export async function acquireLock(filePath: string, opts?: LockCoreOptions): Promise<LockRelease> {
|
|
234
|
+
const target = resolveTarget(filePath);
|
|
235
|
+
const lockfilePath = lockPathOf(target);
|
|
236
|
+
const staleMs = Math.max(opts?.staleMs ?? DEFAULT_STALE_MS, MIN_STALE_MS);
|
|
237
|
+
await ensureParentDir(target);
|
|
238
|
+
installExitHook();
|
|
239
|
+
await acquireOnceAsync(target, lockfilePath, staleMs, opts?.log);
|
|
240
|
+
return makeRelease(lockfilePath);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* acquireLock 的同步版(sync 调用链专用;语义同 async 版,无事件循环依赖)。
|
|
245
|
+
*/
|
|
246
|
+
export function acquireLockSync(filePath: string, opts?: LockCoreOptions): () => void {
|
|
247
|
+
const target = resolveTarget(filePath);
|
|
248
|
+
const lockfilePath = lockPathOf(target);
|
|
249
|
+
const staleMs = Math.max(opts?.staleMs ?? DEFAULT_STALE_MS, MIN_STALE_MS);
|
|
250
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
251
|
+
installExitHook();
|
|
252
|
+
acquireOnceSync(target, lockfilePath, staleMs, opts?.log);
|
|
253
|
+
let released = false;
|
|
254
|
+
return () => {
|
|
255
|
+
if (released) return;
|
|
256
|
+
released = true;
|
|
257
|
+
activeLocks.delete(lockfilePath);
|
|
258
|
+
removeLockSync(lockfilePath);
|
|
259
|
+
};
|
|
260
|
+
}
|