@zhushanwen/pi-file-lock 0.1.1 → 0.1.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-file-lock",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
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
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -18,7 +18,8 @@
18
18
  "index.ts"
19
19
  ],
20
20
  "dependencies": {
21
- "proper-lockfile": "^4.1.2"
21
+ "proper-lockfile": "^4.1.2",
22
+ "@zhushanwen/pi-extension-logger": "0.3.0"
22
23
  },
23
24
  "devDependencies": {
24
25
  "@types/node": "^24.0.0",
@@ -0,0 +1,64 @@
1
+ // src/__tests__/file-lock-compromise.test.ts
2
+ //
3
+ // 锁妥协(compromise)路径单测(真实文件系统 + 真实 proper-lockfile,仅 mock 共享 logger):
4
+ // - onCompromised 标记:持锁期间 lockfile 被外部删除 → proper-lockfile 的 mtime
5
+ // 保活定时器 stat 发现 ENOENT → setLockAsCompromised → onCompromised 回调
6
+ // - unlock 失败留痕:compromised 后 release() 拒绝(ERELEASED)→ finally catch 记
7
+ // logger.debug「不外抛、不静默」——fn 结果不受影响
8
+ //
9
+ // 时序依据(proper-lockfile@4.1.2 lib/lockfile.js):保活间隔 update =
10
+ // max(min(stale/2, stale/2), 1000),staleMs: 2000 时 = 1000ms——fn 内删锁后等待
11
+ // 1300ms 让保活定时器跑完一轮,compromise 必然在 fn 返回前发生。
12
+
13
+ import * as fs from "node:fs";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
+
19
+ import { withFileLock } from "../file-lock.ts";
20
+
21
+ // mock 掉共享 logger,使 loggerMock.debug 可被断言(真实 logger 落盘不可观察)
22
+ const { loggerMock } = vi.hoisted(() => ({
23
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
24
+ }));
25
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
26
+ getLogger: () => loggerMock,
27
+ createLogger: () => loggerMock,
28
+ setPiHandle: vi.fn(),
29
+ }));
30
+
31
+ describe("withFileLock 锁妥协路径(compromise)", () => {
32
+ let tmpDir: string;
33
+ let target: string;
34
+
35
+ beforeEach(() => {
36
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "file-lock-compromise-"));
37
+ target = path.join(tmpDir, "target.json");
38
+ loggerMock.debug.mockClear();
39
+ });
40
+ afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
41
+
42
+ it("fn 执行期间锁被外部删除 → fn 正常返回 + unlock 失败记 debug(不外抛)", async () => {
43
+ const result = await withFileLock(
44
+ target,
45
+ async () => {
46
+ // 模拟持锁进程崩溃后被对端夺取/清理:lockfile 是目录(proper-lockfile 用 mkdir 上锁)
47
+ fs.rmSync(`${target}.lock`, { recursive: true, force: true });
48
+ // 等保活定时器(staleMs:2000 → update 1000ms)stat lockfile 发现 ENOENT
49
+ // → onCompromised 标记 compromised + released=true
50
+ await new Promise((r) => setTimeout(r, 1300));
51
+ return "fn-done";
52
+ },
53
+ { staleMs: 2000 },
54
+ );
55
+
56
+ // compromise 发生在 fn 执行期间:try 开头的 compromised 检查已过,fn 结果原样返回
57
+ expect(result).toBe("fn-done");
58
+ // finally 中 release() 因 released=true 拒绝 ERELEASED → catch 记 debug 留痕
59
+ expect(loggerMock.debug).toHaveBeenCalledTimes(1);
60
+ expect(String(loggerMock.debug.mock.calls[0]![0])).toContain("unlock failed after compromise");
61
+ expect(JSON.stringify(loggerMock.debug.mock.calls[0]![1])).toContain("Lock is already released");
62
+ // 真实 proper-lockfile 计时(staleMs 2000 + 1300ms 等待)+ CI 慢盘,放宽预算防 flaky
63
+ }, 15000);
64
+ });
@@ -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)", () => {
@@ -66,7 +70,9 @@ describe("withFileLock (async)", () => {
66
70
  });
67
71
  await Promise.all(Array.from({ length: 100 }, bump));
68
72
  expect((JSON.parse(fs.readFileSync(target, "utf-8")) as { n: number }).n).toBe(100);
69
- });
73
+ // 100 次并发锁 RMW 是真实文件系统 IO 密集测试,CI 慢盘上逼近 vitest 默认 5s,
74
+ // 放宽时间预算不改变断言强度(终值必须精确 100)
75
+ }, 20000);
70
76
  });
71
77
 
72
78
  describe("withFileLockSync", () => {
package/src/file-lock.ts CHANGED
@@ -32,8 +32,11 @@
32
32
  import { existsSync, mkdirSync } from "node:fs";
33
33
  import { dirname } from "node:path";
34
34
 
35
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
35
36
  import lockfile from "proper-lockfile";
36
37
 
38
+ const logger = getLogger("file-lock");
39
+
37
40
  /** 锁参数(默认值对齐 auth-storage.ts 范本;测试可覆盖以缩短等待)。 */
38
41
  export interface FileLockOptions {
39
42
  /** 锁 mtime 超过该值视为持锁者已死可夺取(stale 语义)。默认 30_000ms。 */
@@ -99,10 +102,7 @@ export async function withFileLock<T>(
99
102
  } catch (unlockErr) {
100
103
  // 锁已 compromised(被 stale 夺取)时 unlock 必然失败且可忽略——
101
104
  // 记录留痕(对齐 pi finally 的 catch 语义:不外抛、不静默)。
102
- console.debug(
103
- "[file-lock] unlock failed after compromise (ignorable):",
104
- unlockErr instanceof Error ? unlockErr.message : String(unlockErr),
105
- );
105
+ logger.debug("unlock failed after compromise (ignorable)", { detail: { err: unlockErr instanceof Error ? unlockErr.message : String(unlockErr) } });
106
106
  }
107
107
  }
108
108
  }