@zhushanwen/pi-file-lock 0.1.1
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/index.ts +7 -0
- package/package.json +32 -0
- package/src/__tests__/file-lock.test.ts +149 -0
- package/src/file-lock.ts +169 -0
- package/src/index.ts +6 -0
package/index.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhushanwen/pi-file-lock",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Shared async cross-process file lock for Pi extensions — proper-lockfile wrapper with stale takeover and exponential-backoff retries, aligned with the runtime-side lock protocol (shared library, not a Pi extension).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi",
|
|
10
|
+
"lock",
|
|
11
|
+
"lockfile",
|
|
12
|
+
"cross-process",
|
|
13
|
+
"shared"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"files": [
|
|
17
|
+
"src/",
|
|
18
|
+
"index.ts"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"proper-lockfile": "^4.1.2"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^24.0.0",
|
|
25
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
26
|
+
"vitest": "^4.1.8"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"typecheck": "npx tsc --noEmit",
|
|
30
|
+
"test": "vitest run"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// src/__tests__/file-lock.test.ts
|
|
2
|
+
//
|
|
3
|
+
// 跨进程文件锁单测(真实文件系统,不 mock fs):
|
|
4
|
+
// - async/sync 版临界区互斥(并发不交错)
|
|
5
|
+
// - unlock 后可再锁(finally 释放语义)
|
|
6
|
+
// - sync 版 fail-fast(ELOCKED 预算耗尽抛错,不用默认 1s——测试覆盖盖短预算)
|
|
7
|
+
// - 真实跨进程互斥:两个 node 子进程并发 RMW 同一 JSON 文件,计数零丢失
|
|
8
|
+
// (「两写方并发不丢条目」的 D5a/D1e 核心验收形态)
|
|
9
|
+
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
17
|
+
|
|
18
|
+
import { withFileLock, withFileLockSync } from "../file-lock.ts";
|
|
19
|
+
|
|
20
|
+
const PKG_DIR = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
|
|
21
|
+
|
|
22
|
+
describe("withFileLock (async)", () => {
|
|
23
|
+
let tmpDir: string;
|
|
24
|
+
let target: string;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "file-lock-test-"));
|
|
28
|
+
target = path.join(tmpDir, "target.json");
|
|
29
|
+
});
|
|
30
|
+
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
|
31
|
+
|
|
32
|
+
it("并发临界区互斥:计数无交错丢失", async () => {
|
|
33
|
+
let counter = 0;
|
|
34
|
+
const inside: number[] = [];
|
|
35
|
+
const tasks = Array.from({ length: 20 }, () =>
|
|
36
|
+
withFileLock(target, async () => {
|
|
37
|
+
counter += 1;
|
|
38
|
+
inside.push(counter);
|
|
39
|
+
// 让出 event loop 制造无锁时必交错的窗口
|
|
40
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
await Promise.all(tasks);
|
|
44
|
+
expect(counter).toBe(20);
|
|
45
|
+
// 每个临界区进入时的 counter 单调 +1(无两个临界区读到同值)
|
|
46
|
+
expect(new Set(inside).size).toBe(20);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("fn 抛错也释放锁(finally 语义:后续可再锁)", async () => {
|
|
50
|
+
await expect(
|
|
51
|
+
withFileLock(target, async () => {
|
|
52
|
+
throw new Error("boom");
|
|
53
|
+
}),
|
|
54
|
+
).rejects.toThrow("boom");
|
|
55
|
+
// 同一 target 立即可再锁 = 前次已释放
|
|
56
|
+
await expect(withFileLock(target, async () => "ok")).resolves.toBe("ok");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("锁内 RMW:并发各 +1 一百次,文件终值 100(丢更新=锁失效)", async () => {
|
|
60
|
+
fs.writeFileSync(target, JSON.stringify({ n: 0 }), "utf-8");
|
|
61
|
+
const bump = (): Promise<void> =>
|
|
62
|
+
withFileLock(target, async () => {
|
|
63
|
+
const cur = JSON.parse(fs.readFileSync(target, "utf-8")) as { n: number };
|
|
64
|
+
cur.n += 1;
|
|
65
|
+
fs.writeFileSync(target, JSON.stringify(cur), "utf-8");
|
|
66
|
+
});
|
|
67
|
+
await Promise.all(Array.from({ length: 100 }, bump));
|
|
68
|
+
expect((JSON.parse(fs.readFileSync(target, "utf-8")) as { n: number }).n).toBe(100);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("withFileLockSync", () => {
|
|
73
|
+
let tmpDir: string;
|
|
74
|
+
let target: string;
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "file-lock-sync-test-"));
|
|
78
|
+
target = path.join(tmpDir, "target.json");
|
|
79
|
+
});
|
|
80
|
+
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
|
81
|
+
|
|
82
|
+
it("返回 fn 结果且锁已释放(可立即再锁)", () => {
|
|
83
|
+
expect(withFileLockSync(target, () => 42)).toBe(42);
|
|
84
|
+
expect(withFileLockSync(target, () => "again")).toBe("again");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("fn 抛错也释放锁", () => {
|
|
88
|
+
expect(() =>
|
|
89
|
+
withFileLockSync(target, () => {
|
|
90
|
+
throw new Error("boom");
|
|
91
|
+
}),
|
|
92
|
+
).toThrow("boom");
|
|
93
|
+
expect(withFileLockSync(target, () => "ok")).toBe("ok");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("ELOCKED fail-fast:预算耗尽抛带 code 的错误(不用默认 1s 预算)", () => {
|
|
97
|
+
// 先占锁(外层 sync 锁),再在 fn 内嵌套取锁 → 必 ELOCKED → 短预算快速失败
|
|
98
|
+
expect(() =>
|
|
99
|
+
withFileLockSync(
|
|
100
|
+
target,
|
|
101
|
+
() =>
|
|
102
|
+
withFileLockSync(target, () => "never", {
|
|
103
|
+
staleMs: 60_000, // stale 远大于预算,锁不会被夺取
|
|
104
|
+
retryDelayMs: 10,
|
|
105
|
+
retryBudgetMs: 30,
|
|
106
|
+
}),
|
|
107
|
+
{ staleMs: 60_000 },
|
|
108
|
+
),
|
|
109
|
+
).toThrowError(/ELOCKED 重试预算 30ms 耗尽/);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("真实跨进程互斥(D5a/D1e 验收形态)", () => {
|
|
114
|
+
it("两个子进程并发 RMW 同一 JSON 各 50 次,终值 100 零丢失", () => {
|
|
115
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "file-lock-xproc-"));
|
|
116
|
+
const target = path.join(tmpDir, "shared.json");
|
|
117
|
+
fs.writeFileSync(target, JSON.stringify({ n: 0 }), "utf-8");
|
|
118
|
+
try {
|
|
119
|
+
// 子进程脚本:--experimental-strip-types 直接跑 TS(Node >= 22.6),
|
|
120
|
+
// 循环 50 次锁内读-改-写。exitCode 非 0 = 子进程自身失败(锁/IO 异常)。
|
|
121
|
+
const worker = `
|
|
122
|
+
import * as fs from "node:fs";
|
|
123
|
+
import { withFileLock } from "${PKG_DIR}/src/file-lock.ts";
|
|
124
|
+
const target = process.argv[2];
|
|
125
|
+
for (let i = 0; i < 50; i++) {
|
|
126
|
+
await withFileLock(target, async () => {
|
|
127
|
+
const cur = JSON.parse(fs.readFileSync(target, "utf-8"));
|
|
128
|
+
cur.n += 1;
|
|
129
|
+
fs.writeFileSync(target, JSON.stringify(cur), "utf-8");
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
`;
|
|
133
|
+
const workerFile = path.join(tmpDir, "worker.ts");
|
|
134
|
+
fs.writeFileSync(workerFile, worker, "utf-8");
|
|
135
|
+
const procs = [1, 2].map(() =>
|
|
136
|
+
spawnSync(process.execPath, ["--experimental-strip-types", workerFile, target], {
|
|
137
|
+
encoding: "utf-8",
|
|
138
|
+
timeout: 60_000,
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
for (const p of procs) {
|
|
142
|
+
expect(p.status).toBe(0);
|
|
143
|
+
}
|
|
144
|
+
expect((JSON.parse(fs.readFileSync(target, "utf-8")) as { n: number }).n).toBe(100);
|
|
145
|
+
} finally {
|
|
146
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
package/src/file-lock.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// src/file-lock.ts
|
|
2
|
+
//
|
|
3
|
+
// 跨进程异步文件锁(extension 侧共享 util,D5a/D1e,integrity-hardening.md §3.5)。
|
|
4
|
+
//
|
|
5
|
+
// 为什么存在:worktrees.json(多 pi 进程各一份扩展实例写)与 ext-config 家族
|
|
6
|
+
// (runtime + 扩展双写)都是跨进程 RMW——Node 单线程只能保证进程内不交错,
|
|
7
|
+
// 挡不住跨进程「后写者基于旧快照覆盖先写者」。
|
|
8
|
+
//
|
|
9
|
+
// 为什么是 async API 而非 runtime 侧的 withFileLockSync:扩展跑在 pi 子进程的
|
|
10
|
+
// async hook 上下文(session_start 等),同步 busy-wait 会阻塞整个 event loop;
|
|
11
|
+
// proper-lockfile 的 async lock() 原生支持 retries 指数退避(sync API 与 retries
|
|
12
|
+
// 组合抛 ESYNC),不需要 runtime 侧那套自实现 busy-wait。
|
|
13
|
+
//
|
|
14
|
+
// 锁协议(与 runtime 侧 packages/runtime/src/utils/file-lock.ts 对齐,登记表
|
|
15
|
+
// docs/architecture/data-source-registry.md §6):
|
|
16
|
+
// - lockfile 路径 = <目标文件>.lock(proper-lockfile 默认,双方路径一致才互斥)
|
|
17
|
+
// - realpath:false —— 目标文件不存在也可锁(realpath 默认 true 时 ENOENT),
|
|
18
|
+
// 与 runtime 侧参数一致;锁前确保父目录存在
|
|
19
|
+
// - stale 30s:持锁进程崩溃后锁可被夺取(对齐 auth 惯例)
|
|
20
|
+
// - async 版 retries 指数退避:10 次 / factor 2 / 100ms~10s / randomize(对齐 pi
|
|
21
|
+
// FileAuthStorageBackend.withLockAsync,见 runtime auth-storage.ts:48-74 范本)
|
|
22
|
+
// - sync 版 busy-wait 重试(25ms / 预算 1s fail-fast):对齐 runtime 侧
|
|
23
|
+
// withFileLockSync——proper-lockfile 的 sync API 与 retries 组合抛 ESYNC,
|
|
24
|
+
// 重试必须在外层同步循环做;ext-config 家族的扩展侧写方(saveConfig)
|
|
25
|
+
// 保持 sync 签名(调用链零波及),与 runtime 对端用同一把 lockfile 互斥
|
|
26
|
+
// - onCompromised:锁被判定 stale 夺取时标记,fn 执行前抛错——防止在失去
|
|
27
|
+
// 互斥保证的锁下写盘(对齐 pi throwIfCompromised 语义)
|
|
28
|
+
//
|
|
29
|
+
// 契约:fn 内禁止任何 await / 再次对本文件加锁(嵌套取锁 ELOCKED → 重试耗尽 →
|
|
30
|
+
// 抛错);持锁范围应为「读文件 + 纯内存变更 + 原子写」,毫秒级。
|
|
31
|
+
|
|
32
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
33
|
+
import { dirname } from "node:path";
|
|
34
|
+
|
|
35
|
+
import lockfile from "proper-lockfile";
|
|
36
|
+
|
|
37
|
+
/** 锁参数(默认值对齐 auth-storage.ts 范本;测试可覆盖以缩短等待)。 */
|
|
38
|
+
export interface FileLockOptions {
|
|
39
|
+
/** 锁 mtime 超过该值视为持锁者已死可夺取(stale 语义)。默认 30_000ms。 */
|
|
40
|
+
staleMs?: number;
|
|
41
|
+
/** retries 总次数。默认 10。 */
|
|
42
|
+
retries?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** sync 版锁参数(对齐 runtime withFileLockSync 默认值;测试可覆盖以缩短等待)。 */
|
|
46
|
+
export interface SyncFileLockOptions {
|
|
47
|
+
/** 锁 mtime 超过该值视为持锁者已死可夺取(stale 语义)。默认 30_000ms。 */
|
|
48
|
+
staleMs?: number;
|
|
49
|
+
/** ELOCKED 重试间隔(同步 sleep)。默认 25ms。 */
|
|
50
|
+
retryDelayMs?: number;
|
|
51
|
+
/** ELOCKED 重试总预算,耗尽 fail-fast。默认 1_000ms。 */
|
|
52
|
+
retryBudgetMs?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 默认锁参数(sync 版导出供对照测试断言与 runtime 侧 utils/file-lock.ts 默认值相等
|
|
56
|
+
// ——两侧参数漂移会破坏「同一把锁」的互斥语义;runtime 侧 test/file-lock-parity.test.ts)
|
|
57
|
+
export const DEFAULT_STALE_MS = 30_000;
|
|
58
|
+
const DEFAULT_RETRIES = 10;
|
|
59
|
+
export const DEFAULT_RETRY_DELAY_MS = 25;
|
|
60
|
+
export const DEFAULT_RETRY_BUDGET_MS = 1_000;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 跨进程文件锁内执行 async fn:拿不到锁时指数退避重试(100ms~10s/randomize),
|
|
64
|
+
* 重试耗尽抛 proper-lockfile 的 ELOCKED 错误(调用方决定降级路径);
|
|
65
|
+
* unlock 放 finally(fn 抛错也释放,compromised 时 unlock 失败可忽略)。
|
|
66
|
+
*/
|
|
67
|
+
export async function withFileLock<T>(
|
|
68
|
+
filePath: string,
|
|
69
|
+
fn: () => Promise<T>,
|
|
70
|
+
opts?: FileLockOptions,
|
|
71
|
+
): Promise<T> {
|
|
72
|
+
// 锁前确保父目录存在:proper-lockfile 创建 lockfile(<目标>.lock)需要目录在
|
|
73
|
+
const dir = dirname(filePath);
|
|
74
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
75
|
+
|
|
76
|
+
// onCompromised:锁被 stale 夺取(进程卡死超时等)时标记,fn 执行前抛错,
|
|
77
|
+
// 防止在失去互斥保证的锁下写盘(对齐 pi throwIfCompromised)。
|
|
78
|
+
let compromised: Error | undefined;
|
|
79
|
+
const release = await lockfile.lock(filePath, {
|
|
80
|
+
realpath: false,
|
|
81
|
+
retries: {
|
|
82
|
+
retries: opts?.retries ?? DEFAULT_RETRIES,
|
|
83
|
+
factor: 2,
|
|
84
|
+
minTimeout: 100,
|
|
85
|
+
maxTimeout: 10_000,
|
|
86
|
+
randomize: true,
|
|
87
|
+
},
|
|
88
|
+
stale: opts?.staleMs ?? DEFAULT_STALE_MS,
|
|
89
|
+
onCompromised: (err: Error) => {
|
|
90
|
+
compromised = err;
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
try {
|
|
94
|
+
if (compromised) throw compromised;
|
|
95
|
+
return await fn();
|
|
96
|
+
} finally {
|
|
97
|
+
try {
|
|
98
|
+
await release();
|
|
99
|
+
} catch (unlockErr) {
|
|
100
|
+
// 锁已 compromised(被 stale 夺取)时 unlock 必然失败且可忽略——
|
|
101
|
+
// 记录留痕(对齐 pi finally 的 catch 语义:不外抛、不静默)。
|
|
102
|
+
console.debug(
|
|
103
|
+
"[file-lock] unlock failed after compromise (ignorable):",
|
|
104
|
+
unlockErr instanceof Error ? unlockErr.message : String(unlockErr),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 同步跨进程文件锁内执行 fn:lockSync(realpath:false) + ELOCKED busy-wait 重试,
|
|
112
|
+
* 预算耗尽抛带 ELOCKED code 的错误;unlock 放 finally(fn 抛错也释放)。
|
|
113
|
+
*
|
|
114
|
+
* 与 async 版锁同一把 lockfile(<目标文件>.lock)——sync/async API 在磁盘上
|
|
115
|
+
* 是同一协议,互斥不依赖调用形态。适用场景:调用链必须保持 sync 签名
|
|
116
|
+
* (如 llm-shared saveConfig,permission/rename-session 的命令回调零波及)。
|
|
117
|
+
* sleep 用 Atomics.wait(真 sleep 不烧 CPU,对齐 runtime 侧实现)。
|
|
118
|
+
*/
|
|
119
|
+
export function withFileLockSync<T>(
|
|
120
|
+
filePath: string,
|
|
121
|
+
fn: () => T,
|
|
122
|
+
opts?: SyncFileLockOptions,
|
|
123
|
+
): T {
|
|
124
|
+
const staleMs = opts?.staleMs ?? DEFAULT_STALE_MS;
|
|
125
|
+
const retryDelayMs = opts?.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
|
126
|
+
const retryBudgetMs = opts?.retryBudgetMs ?? DEFAULT_RETRY_BUDGET_MS;
|
|
127
|
+
|
|
128
|
+
const dir = dirname(filePath);
|
|
129
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
130
|
+
|
|
131
|
+
const deadline = Date.now() + retryBudgetMs;
|
|
132
|
+
let release: (() => void) | undefined;
|
|
133
|
+
while (release === undefined) {
|
|
134
|
+
try {
|
|
135
|
+
release = lockfile.lockSync(filePath, { realpath: false, stale: staleMs });
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if (!isElocked(err)) throw err;
|
|
138
|
+
if (Date.now() >= deadline) {
|
|
139
|
+
throw Object.assign(
|
|
140
|
+
new Error(
|
|
141
|
+
`[file-lock] ${filePath} 写锁获取失败:ELOCKED 重试预算 ${retryBudgetMs}ms 耗尽` +
|
|
142
|
+
`(持锁方临界区异常或已崩溃,stale ${staleMs}ms 后可夺取)。恢复指引:稍后重试本次写入。`,
|
|
143
|
+
// cause 挂原始 ELOCKED 错误,保留 proper-lockfile 诊断信息
|
|
144
|
+
{ cause: err },
|
|
145
|
+
),
|
|
146
|
+
{ code: "ELOCKED" },
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
sleepSync(retryDelayMs);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
return fn();
|
|
154
|
+
} finally {
|
|
155
|
+
release();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isElocked(err: unknown): boolean {
|
|
160
|
+
// in 收窄而非 as 断言(extensions taste/no-unsafe-catch:全可选属性断言 = 无校验)
|
|
161
|
+
return err instanceof Error && "code" in err && err.code === "ELOCKED";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Atomics.wait 需要一个共享内存对象作等待目标;4 字节 = 一个 Int32 元素,仅占位不被写入。 */
|
|
165
|
+
const SLEEP_WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
|
|
166
|
+
|
|
167
|
+
function sleepSync(ms: number): void {
|
|
168
|
+
Atomics.wait(SLEEP_WAIT_BUFFER, 0, 0, ms);
|
|
169
|
+
}
|