@zhushanwen/pi-llm-shared 0.3.0 → 0.3.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/package.json +5 -1
- package/src/__tests__/config.test.ts +38 -8
- package/src/config.ts +70 -36
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-llm-shared",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Shared LLM invocation library for Pi extensions — model resolution (ref exact only), LLM calling (completeSimple), and config read/write with mtime caching. Shared library, not a Pi extension.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
"src/",
|
|
18
18
|
"index.ts"
|
|
19
19
|
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@zhushanwen/pi-file-lock": "0.1.1"
|
|
22
|
+
},
|
|
20
23
|
"peerDependencies": {
|
|
21
24
|
"@earendil-works/pi-ai": "*",
|
|
22
25
|
"@earendil-works/pi-coding-agent": "*"
|
|
@@ -32,6 +35,7 @@
|
|
|
32
35
|
"devDependencies": {
|
|
33
36
|
"@earendil-works/pi-ai": "*",
|
|
34
37
|
"@earendil-works/pi-coding-agent": "*",
|
|
38
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
35
39
|
"vitest": "^4.1.8"
|
|
36
40
|
},
|
|
37
41
|
"scripts": {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
|
|
7
7
|
import { clearConfigCache, getConfigPath, loadConfig, saveConfig } from "../config.ts";
|
|
8
|
+
import * as fileLock from "@zhushanwen/pi-file-lock";
|
|
8
9
|
|
|
9
10
|
// node:fs 的 ESM namespace 不可配置,vi.spyOn 对具名导出失效(vitest 限制)。
|
|
10
11
|
// 用 vi.mock 包装 readFileSync/renameSync(默认走 actual,个别 test override),
|
|
@@ -128,6 +129,12 @@ describe("loadConfig", () => {
|
|
|
128
129
|
});
|
|
129
130
|
|
|
130
131
|
describe("saveConfig", () => {
|
|
132
|
+
/** tmp 残留断言(D1e 唯一化后 tmp 名为 <path>.tmp_<pid>_<rand>,用前缀 glob 断言)。 */
|
|
133
|
+
function tmpResidues(pkg: string): string[] {
|
|
134
|
+
const cfgDir = join(dir, "config");
|
|
135
|
+
return readdirSync(cfgDir).filter((f) => f.startsWith(`${pkg}-ext-config.json.tmp`));
|
|
136
|
+
}
|
|
137
|
+
|
|
131
138
|
it("TC16 原子写:文件落盘 + 内容正确 + 无 tmp 残留", () => {
|
|
132
139
|
const result = saveConfig("test", { b: 2 });
|
|
133
140
|
expect(result.success).toBe(true);
|
|
@@ -135,7 +142,7 @@ describe("saveConfig", () => {
|
|
|
135
142
|
const cfgPath = join(dir, "config", "test-ext-config.json");
|
|
136
143
|
expect(existsSync(cfgPath)).toBe(true);
|
|
137
144
|
expect(JSON.parse(readFileSync(cfgPath, "utf-8"))).toEqual({ b: 2 });
|
|
138
|
-
expect(
|
|
145
|
+
expect(tmpResidues("test")).toEqual([]); // 无 tmp 残留(唯一化 tmp 名,前缀断言)
|
|
139
146
|
});
|
|
140
147
|
|
|
141
148
|
it("TC16 文件 mode 0o600", () => {
|
|
@@ -164,8 +171,8 @@ describe("saveConfig", () => {
|
|
|
164
171
|
|
|
165
172
|
expect(result.success).toBe(false);
|
|
166
173
|
expect(result.error).toContain("EPERM");
|
|
167
|
-
// tmp 文件被 catch 块的 unlinkSync
|
|
168
|
-
expect(
|
|
174
|
+
// tmp 文件被 catch 块的 unlinkSync 清理(唯一化 tmp 名,前缀断言)
|
|
175
|
+
expect(tmpResidues("fail")).toEqual([]);
|
|
169
176
|
// 目标文件未被创建(rename 失败)
|
|
170
177
|
expect(existsSync(join(dir, "config", "fail-ext-config.json"))).toBe(false);
|
|
171
178
|
});
|
|
@@ -185,9 +192,10 @@ describe("saveConfig", () => {
|
|
|
185
192
|
expect(onWarning).toHaveBeenCalledTimes(1);
|
|
186
193
|
const warning = String(onWarning.mock.calls[0][0]);
|
|
187
194
|
expect(warning).toContain("[llm-shared] Failed to save config at '" + join(dir, "config", "enoent-ext-config.json") + "'");
|
|
195
|
+
expect(warning).toContain("[llm-shared] Failed to save config at '" + join(dir, "config", "enoent-ext-config.json") + "'");
|
|
188
196
|
expect(warning).toContain("ENOENT");
|
|
189
|
-
// tmp
|
|
190
|
-
expect(
|
|
197
|
+
// tmp 清理(前缀断言)+ 目标未创建
|
|
198
|
+
expect(tmpResidues("enoent")).toEqual([]);
|
|
191
199
|
expect(existsSync(join(dir, "config", "enoent-ext-config.json"))).toBe(false);
|
|
192
200
|
});
|
|
193
201
|
|
|
@@ -204,8 +212,8 @@ describe("saveConfig", () => {
|
|
|
204
212
|
const warning = String(onWarning.mock.calls[0][0]);
|
|
205
213
|
expect(warning).toContain("[llm-shared] Failed to save config at '" + join(dir, "config", "eperm-ext-config.json") + "'");
|
|
206
214
|
expect(warning).toContain("EPERM");
|
|
207
|
-
// tmp 清理(Windows 目标占用场景 rename 失败后 tmp
|
|
208
|
-
expect(
|
|
215
|
+
// tmp 清理(Windows 目标占用场景 rename 失败后 tmp 残留被清理;前缀断言)
|
|
216
|
+
expect(tmpResidues("eperm")).toEqual([]);
|
|
209
217
|
expect(existsSync(join(dir, "config", "eperm-ext-config.json"))).toBe(false);
|
|
210
218
|
});
|
|
211
219
|
|
|
@@ -214,4 +222,26 @@ describe("saveConfig", () => {
|
|
|
214
222
|
expect(saveConfig("test", { v: 2 }).success).toBe(true);
|
|
215
223
|
expect(JSON.parse(readFileSync(join(dir, "config", "test-ext-config.json"), "utf-8"))).toEqual({ v: 2 });
|
|
216
224
|
});
|
|
225
|
+
|
|
226
|
+
it("W4 锁不可用(ELOCKED 预算耗尽)→ {success:false} + 不降级无锁写(目标文件不落盘)", () => {
|
|
227
|
+
// 模拟 runtime 对端长期持锁:withFileLockSync 抛 ELOCKED。扩展侧契约 =
|
|
228
|
+
// 不降级无锁写(降级会与 runtime 持锁写交错丢字段),按保存失败返回。
|
|
229
|
+
const lockErr = Object.assign(new Error("[file-lock] lock unavailable: ELOCKED"), { code: "ELOCKED" });
|
|
230
|
+
const spy = vi.spyOn(fileLock, "withFileLockSync").mockImplementation(() => {
|
|
231
|
+
throw lockErr;
|
|
232
|
+
});
|
|
233
|
+
const onWarning = vi.fn();
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const result = saveConfig("lockbusy", { x: 1 }, onWarning);
|
|
237
|
+
|
|
238
|
+
expect(result.success).toBe(false);
|
|
239
|
+
expect(result.error).toContain("lock unavailable");
|
|
240
|
+
expect(onWarning).toHaveBeenCalledTimes(1);
|
|
241
|
+
// 关键:未降级写盘(无锁写会破坏与 runtime 的互斥)
|
|
242
|
+
expect(existsSync(join(dir, "config", "lockbusy-ext-config.json"))).toBe(false);
|
|
243
|
+
} finally {
|
|
244
|
+
spy.mockRestore();
|
|
245
|
+
}
|
|
246
|
+
});
|
|
217
247
|
});
|
package/src/config.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync,
|
|
|
23
23
|
import { dirname, join } from "node:path";
|
|
24
24
|
|
|
25
25
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import { withFileLockSync } from "@zhushanwen/pi-file-lock";
|
|
26
27
|
|
|
27
28
|
/** JSON 序列化缩进格数(permission/config.ts 同款)。 */
|
|
28
29
|
const JSON_INDENT = 2;
|
|
@@ -114,13 +115,35 @@ export function loadConfig<T>(
|
|
|
114
115
|
}
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
// ────────────────────────
|
|
118
|
+
// ──────────────────────── 保存(锁内原子写) ────────────────────────
|
|
118
119
|
|
|
119
120
|
/**
|
|
120
|
-
*
|
|
121
|
+
* 生成并发唯一的 tmp 文件名(D1e 附带风险修复:双侧 tmp 中间文件同名 `<path>.tmp`
|
|
122
|
+
* 并发可碰撞——runtime 侧 atomicWrite 未传 uniqueSuffix 也是固定名,扩展侧唯一化
|
|
123
|
+
* 后两侧名字空间不相交,碰撞面消除)。
|
|
124
|
+
*
|
|
125
|
+
* 后缀 = pid + 36 进制随机段:同进程多写方(多 session)与跨进程写方均不重名。
|
|
126
|
+
*/
|
|
127
|
+
const TMP_RANDOM_BASE = 36;
|
|
128
|
+
const TMP_RANDOM_SLICE_START = 2; // 跳过 Math.random 字符串的 "0." 前缀
|
|
129
|
+
const TMP_RANDOM_SLICE_END = 10;
|
|
130
|
+
function uniqueTmpPath(configPath: string): string {
|
|
131
|
+
return `${configPath}.tmp_${process.pid}_${Math.random().toString(TMP_RANDOM_BASE).slice(TMP_RANDOM_SLICE_START, TMP_RANDOM_SLICE_END)}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 保存配置(锁内原子写:withFileLockSync + tmp 文件 + rename)。
|
|
121
136
|
*
|
|
122
137
|
* @returns 成功 {success:true};失败 {success:false, error}
|
|
123
138
|
*
|
|
139
|
+
* 🔒 跨进程锁(D1e/W4,integrity-hardening.md §3.1,登记表 §6 rename-session 行):
|
|
140
|
+
* ext-config 家族被 xyz runtime(如 setRenameModel 写 model 字段,W1b 已持锁)与
|
|
141
|
+
* pi 子进程内扩展(本函数)双写。互斥只依赖同一 lockfile(<config>.lock),本侧
|
|
142
|
+
* withFileLockSync 协议与 runtime 侧 settings.json 写锁逐字对齐(realpath:false +
|
|
143
|
+
* stale 30s + busy-wait 1s 预算 fail-fast)。锁获取失败不降级无锁写——对端
|
|
144
|
+
* runtime 可能正持锁写,无锁写会交错丢字段;返回 {success:false} 由调用方按
|
|
145
|
+
* 保存失败处理(下次 save 重试)。
|
|
146
|
+
*
|
|
124
147
|
* 原子性:writeFileSync(tmp) + renameSync(tmp→target),rename 是原子的(POSIX/Windows)。
|
|
125
148
|
* tmp 失败清理(review RK3):writeFileSync 或 renameSync 抛错时,catch 块 unlinkSync(tmp)
|
|
126
149
|
* 清理残留 tmp 文件(unlink 本身 try/catch,避免二次抛错)。
|
|
@@ -137,44 +160,55 @@ export function saveConfig(
|
|
|
137
160
|
onWarning?: (msg: string) => void,
|
|
138
161
|
): { success: boolean; error?: string } {
|
|
139
162
|
const configPath = getConfigPath(pkgName);
|
|
140
|
-
const tmpPath =
|
|
163
|
+
const tmpPath = uniqueTmpPath(configPath);
|
|
141
164
|
const content = `${JSON.stringify(config, null, JSON_INDENT)}\n`;
|
|
142
165
|
|
|
143
|
-
|
|
144
|
-
mkdirSync(dirname(configPath), { recursive: true });
|
|
145
|
-
writeFileSync(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
|
|
146
|
-
renameSync(tmpPath, configPath);
|
|
147
|
-
|
|
148
|
-
// 写后更新缓存(用新文件 mtime+size + 写入的 config)
|
|
166
|
+
const writeLocked = (): { success: boolean; error?: string } => {
|
|
149
167
|
try {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
168
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
169
|
+
writeFileSync(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
|
|
170
|
+
renameSync(tmpPath, configPath);
|
|
171
|
+
|
|
172
|
+
// 写后更新缓存(用新文件 mtime+size + 写入的 config)
|
|
173
|
+
try {
|
|
174
|
+
const newStat = statSync(configPath);
|
|
175
|
+
configCache.set(configPath, {
|
|
176
|
+
mtimeMs: newStat.mtimeMs,
|
|
177
|
+
size: newStat.size,
|
|
178
|
+
config: clone(config),
|
|
179
|
+
});
|
|
180
|
+
} catch (statErr) {
|
|
181
|
+
// stat 失败不影响保存成功;缓存下次 load 时会重读
|
|
182
|
+
console.warn(
|
|
183
|
+
`[llm-shared] saveConfig stat after write failed:`,
|
|
184
|
+
statErr instanceof Error ? statErr.message : String(statErr),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { success: true };
|
|
189
|
+
} catch (error) {
|
|
190
|
+
// RK3: 清理残留 tmp 文件(writeFileSync 或 renameSync 失败时 tmp 可能残留)
|
|
191
|
+
try {
|
|
192
|
+
if (existsSync(tmpPath)) unlinkSync(tmpPath);
|
|
193
|
+
} catch (cleanupErr) {
|
|
194
|
+
// tmp 清理失败不能阻塞保存失败的返回;记录原因
|
|
195
|
+
console.warn(
|
|
196
|
+
`[llm-shared] saveConfig tmp cleanup failed:`,
|
|
197
|
+
cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
201
|
+
onWarning?.(`[llm-shared] Failed to save config at '${configPath}': ${message}`);
|
|
202
|
+
return { success: false, error: `Failed to save config at '${configPath}': ${message}` };
|
|
162
203
|
}
|
|
204
|
+
};
|
|
163
205
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
console.warn(
|
|
172
|
-
`[llm-shared] saveConfig tmp cleanup failed:`,
|
|
173
|
-
cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr),
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
177
|
-
onWarning?.(`[llm-shared] Failed to save config at '${configPath}': ${message}`);
|
|
178
|
-
return { success: false, error: `Failed to save config at '${configPath}': ${message}` };
|
|
206
|
+
try {
|
|
207
|
+
return withFileLockSync(configPath, writeLocked);
|
|
208
|
+
} catch (lockErr) {
|
|
209
|
+
// 锁获取失败(ELOCKED 预算耗尽等):不降级无锁写(见 docstring),按保存失败返回
|
|
210
|
+
const message = lockErr instanceof Error ? lockErr.message : String(lockErr);
|
|
211
|
+
onWarning?.(`[llm-shared] Config write lock unavailable at '${configPath}': ${message}`);
|
|
212
|
+
return { success: false, error: `Config write lock unavailable at '${configPath}': ${message}` };
|
|
179
213
|
}
|
|
180
214
|
}
|