@mycf711/dsh-gate 0.1.2 → 0.1.3
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/README.md +31 -6
- package/lib/config-lock.js +588 -0
- package/lib/index.js +103 -40
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
- 零运行时依赖,纯 ESM,Node `>= 22.19.0`
|
|
9
9
|
- 默认不联网:语义复审默认关闭,打开也只在 `merge` / `release` 上生效
|
|
10
10
|
- 发往模型前先脱敏;模型不可用时**放行并给 warning**,不阻断硬门禁
|
|
11
|
-
- 配置写盘带 `.bak`
|
|
11
|
+
- 配置写盘带 `.bak` 备份、跨进程写锁与原子替换(Windows EPERM/EBUSY 有界重试)
|
|
12
12
|
|
|
13
13
|
## 安装
|
|
14
14
|
|
|
@@ -157,7 +157,8 @@ if (!result.ok) return
|
|
|
157
157
|
| `lintText(text, config, options?)` | `{ ok, hits }` | `options.trigger` / `options.current`;跳过 `enabled: false` 的规则 |
|
|
158
158
|
| `validateConfig(input)` | `{ ok: true, config }` 或 `{ ok: false, error }` | 写盘 / 面板保存前校验;`enabled` 必须是布尔、复启必须写完整规则;`extends` 只校验形状并原样带回 |
|
|
159
159
|
| `readGateScope(scope, options?)` | `{ path, config, error? }` | `scope` = `default` / `user` / `project` / `local` |
|
|
160
|
-
| `writeGateConfig(scope, config, options?)` | `{ path, config }` | 只允许 `user` / `project` / `local
|
|
160
|
+
| `writeGateConfig(scope, config, options?)` | `{ path, config, lock }` | 只允许 `user` / `project` / `local`;同一路径**跨进程写锁**内「读旧文件 → 校验 → `.bak` → 临时写入 → rename」;共享 `extends` 抛错(`code: 'extends_not_preservable'`),锁超时抛错(`code: 'gate_lock_timeout'`);`options` 支持 `lockTimeoutMs` / `lockPollMs` / `lockStaleMs` / `lockGuardStaleMs` / `renameAttempts` / `renameBackoffMs`;`lock.broken` 是本次接管的 stale 锁证据(年龄 + 所有者) |
|
|
161
|
+
| `acquireGateConfigLock(path, options?)` / `renameWithRetry(from, to, options?)` | 锁句柄 / `{ attempts }` | 写盘用的两个原语:跨进程写锁(`gateLockPath(path)` = `<配置>.lock`,接管段另有 `gateGuardPath(path)` = `<配置>.lock.guard` 守护锁)与 EPERM/EBUSY 有界退避重命名;锁句柄带 `release()` / `isOwner()` / `assertOwner()` |
|
|
161
162
|
| `userGatePath` / `projectGatePath` / `localGatePath` | `string` | 路径派生 |
|
|
162
163
|
| `DEFAULT_GATE_PATH` / `TRIGGERS` / `RULE_TYPES` | 常量 | 默认规则路径与枚举 |
|
|
163
164
|
|
|
@@ -264,7 +265,18 @@ printf '正文' | node bin/gate.mjs --trigger pr.reply --body-file - --current 1
|
|
|
264
265
|
- **默认不联网**:语义复审默认关闭;开启后也只连配置里的 `baseURL`。
|
|
265
266
|
- **先脱敏再出网**:`redact()` 覆盖 GitHub / GitLab / Slack / Stripe / npm / AWS / Google /
|
|
266
267
|
JWT / PEM / URL 密码 / 查询参数 / Gitee 令牌等形状。
|
|
267
|
-
- **写盘可回滚**:`writeGateConfig` 先写 `.bak`,临时文件 `0o600`,`rename`
|
|
268
|
+
- **写盘可回滚**:`writeGateConfig` 先写 `.bak`,临时文件 `0o600`,`rename` 原子替换
|
|
269
|
+
(Windows 上目标文件被占用时 EPERM/EBUSY 有界退避重试;最终失败保留原文件、清理临时文件)。
|
|
270
|
+
- **写盘串行**:同一配置路径用锁文件(`<gates.json>.lock`,`O_EXCL` 原子创建)把
|
|
271
|
+
「读旧文件 → 校验 → 备份 → 临时写入 → rename」整段串起来,跨进程也不会互相覆盖。
|
|
272
|
+
拿不到锁**显式失败**(`code: 'gate_lock_timeout'`,错误里带持有者 pid/host/开始时间与锁年龄),
|
|
273
|
+
绝不退回无锁写;只有超龄或所有者已死的锁才会被接管,接管证据在返回值 `lock.broken` 里。
|
|
274
|
+
Windows 上并发创建同一锁文件拿到的是 `EPERM` / `EBUSY`(不是 `EEXIST`),按竞争等待重试。
|
|
275
|
+
- **接管不误伤**:创建 / 接管 / 释放全部串行在 `<lock>.guard` 守护锁里;搬走 stale 锁之后**再核对**
|
|
276
|
+
token(内容不可解析时比对原文),发现搬错了别人刚建的新锁就原样放回去;释放则是**先核对再从原位删**,
|
|
277
|
+
token 不匹配(例如自己被判定 stale 接管过)时一个字节都不碰 —— 不先 rename,就不存在「把当前持有者的锁
|
|
278
|
+
搬走 → `lockPath` 空窗 → 第三写者趁虚而入」的窗口;拿不到守护锁也宁可留着自己的锁。写盘前还有一道
|
|
279
|
+
`assertOwner()`:锁一旦不在自己手里,宁可失败(`code: 'gate_lock_lost'`)也不覆盖别人的写盘。
|
|
268
280
|
- **写盘不丢 `extends`**:整层重写只写 `version/name/rules/semantic`;`false` / `"none"` 原样保留,
|
|
269
281
|
共享形式(`true` / `"default"` / 路径 / 对象)显式报错并点名配置路径 —— 宁可保存失败,也不悄悄冲掉共享规则。
|
|
270
282
|
- **坏配置 fail-open(仅语义复审)**:语义层不可用时放行并给 warning;确定性规则仍逐条执行。
|
|
@@ -280,7 +292,12 @@ node --test
|
|
|
280
292
|
`false` / `"none"` 原样保留、读回仍是替换、坏 JSON 先进 `.bak`)、脱敏 / 路径 / 无关引用、
|
|
281
293
|
规则类型与触发器过滤、规则启停(`enabled` 缺省 / 停用 / 后层复启 / 写盘往返 / 非法形状)、
|
|
282
294
|
语义复审只 merge/release 与 fail-open、硬门禁失败不请求模型、CLI 的 PASS/FAIL 退出码、
|
|
283
|
-
默认 JSON 可解析且 10
|
|
295
|
+
默认 JSON 可解析且 10 条规则都真的能命中、**跨进程写锁**(真实子进程:并发「读-改-写」计数一次不少、
|
|
296
|
+
两个写者同层写入不丢 `extends` / 不产生损坏文件 / 不留临时文件)、锁被占用时显式超时失败且不写盘、
|
|
297
|
+
stale 锁的年龄与所有者证据、rename 的 EPERM/EBUSY 退避重试与最终失败清理、
|
|
298
|
+
**stale 接管的并发安全**(12 写者栅栏同时抢同一把 stale 锁 × 多轮,临界区不重叠、无 DUP_READS,
|
|
299
|
+
无 stale 正对照;stale 守护锁接管;**release 窗口**:旧持有者被挂起、锁被接管、恢复后释放时,
|
|
300
|
+
第三写者不得进临界区)。
|
|
284
301
|
|
|
285
302
|
## 未验项
|
|
286
303
|
|
|
@@ -289,8 +306,16 @@ node --test
|
|
|
289
306
|
- 面板 UI(G3)与主插件路由(G2)不在本包范围。
|
|
290
307
|
- 超大配置文件(数千条规则)的性能未压测。
|
|
291
308
|
- `extends` 跨盘符 / UNC 路径未单独验证。
|
|
292
|
-
-
|
|
293
|
-
|
|
309
|
+
- 写锁是**同机**互斥:锁判定依赖 `mtime` 与 `process.kill(pid, 0)`,跨主机共享盘(NFS / SMB)
|
|
310
|
+
上的时钟与 pid 语义未验证;跨主机并发写同一层不在保证范围。
|
|
311
|
+
- 真实并发 `rename` 竞争在 Windows 上已复现 EPERM(旧实现 12 轮里 11 轮失败);
|
|
312
|
+
「另一个进程长期持有目标文件句柄」造成的 EPERM 用替身验证退避重试,未做真实句柄占用压测。
|
|
313
|
+
- 守护锁的接管阈值是 10s:持有者被**挂起**(不是崩溃,pid 还活着)超过 10s 时会被当作 stale 接管。
|
|
314
|
+
守护区内的二次校验 + `O_EXCL` 创建保证不会搬走别人的锁,但这种「双持」窗口没有专门压测。
|
|
315
|
+
- 与**不经过本锁**的外部写者(例如旧版本进程直接改 `gates.json`)并发时,只能靠 `assertOwner()` 兜底;
|
|
316
|
+
这一点不在测试覆盖范围内。
|
|
317
|
+
- 释放时若 2s 内拿不到守护锁(例如守护锁持有者被挂起),`release()` 返回 false 且**不碰**锁文件:
|
|
318
|
+
自己的锁会留到超龄(默认 30s)才被下一次写入接管。这条「不碰」路径没有专门压测。
|
|
294
319
|
|
|
295
320
|
## License
|
|
296
321
|
|
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同一配置路径的跨进程写锁 + 原子替换(rename)重试。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要:`writeGateConfig()` 是「读旧文件(extends)→ 校验 → 备份 → 临时写入 → rename」
|
|
5
|
+
* 五步,两个写者(面板保存、CLI、主插件)同时进来时,后写者可能拿着**旧快照**通过校验,
|
|
6
|
+
* 再把先写者刚写下的 `extends: "none"` / `false` 冲掉 —— 语义从「替换」静默退化成「合并」。
|
|
7
|
+
* 锁文件用 `open(path, 'wx')` 原子创建(O_EXCL,Windows / POSIX 都成立),把整段串行起来。
|
|
8
|
+
*
|
|
9
|
+
* 三条硬约束(docs/任务书-dsh-gate-v4.md「门禁写盘」):
|
|
10
|
+
* 1. 拿不到锁**显式失败**,绝不退回无锁写;
|
|
11
|
+
* 2. 只有**超龄**(或所有者进程已死)的锁才算 stale,清理时留下年龄与所有者证据;
|
|
12
|
+
* 3. Windows 上目标文件被占用时 rename 会 EPERM / EBUSY:有界退避重试,最终失败保留原文件。
|
|
13
|
+
*
|
|
14
|
+
* 并发安全(C2/C3):`rename` / `unlink` 都是**无条件**操作 —— 只看一眼旧快照就动 `lockPath`,
|
|
15
|
+
* 可能搬走别的写者刚创建的新锁,于是两个写者同时进临界区(Q1 实测 12 写者 2/44 轮)。
|
|
16
|
+
* 所以「创建 / 接管 / 释放」这三个会动 `lockPath` 的动作全部走:
|
|
17
|
+
* 1. **守护锁**(`<lock>.guard`,微秒级、同样 O_EXCL):把「读快照 → 判定 stale → 搬走 → 新建」
|
|
18
|
+
* 以及「核对 token → 删除」串起来,活着的写者不会互相打断;
|
|
19
|
+
* 2. **搬走后再核对**:rename 到唯一名字后比对 token(内容不可解析时比对原文),
|
|
20
|
+
* 不是快照里那把(搬错了别人刚建的新锁)就用 `link` / `COPYFILE_EXCL` **原子放回去**,绝不误删;
|
|
21
|
+
* 3. **释放先核对、不匹配不碰**(C3/Q1R):释放**不能**先 rename —— 旧持有者被挂起、锁被接管、
|
|
22
|
+
* 恢复后 release 时,先 rename 会把当前持有者的新锁搬走,`lockPath` 空窗,第三写者趁机进临界区。
|
|
23
|
+
* 现在的顺序是「进守护区 → 核对 token → 仍是自己的才从原位删」,不匹配原样返回 false;
|
|
24
|
+
* 拿不到守护锁也不碰 `lockPath`(宁可留着自己的锁,也不误删别人的新锁)。
|
|
25
|
+
* 再加一道**写盘前归属校验**(`assertOwner()`):锁一旦不在自己手里,宁可失败也不覆盖别人的写盘。
|
|
26
|
+
*
|
|
27
|
+
* 锁是**跨进程互斥**,不是分布式锁:判据是同一台机器、同一路径;跨主机共享盘不保证。
|
|
28
|
+
* 超龄接管的含义:单次写盘耗时超过 `staleMs` 时理论上可能被并发接管 —— 所以默认 30s,
|
|
29
|
+
* 远大于一次「读 → 校验 → 写 → rename」的毫秒级窗口;崩在中间的锁会留下文件,由下一次写入接管。
|
|
30
|
+
*/
|
|
31
|
+
import {
|
|
32
|
+
closeSync,
|
|
33
|
+
constants as fsConstants,
|
|
34
|
+
copyFileSync,
|
|
35
|
+
linkSync,
|
|
36
|
+
openSync,
|
|
37
|
+
readFileSync,
|
|
38
|
+
renameSync,
|
|
39
|
+
statSync,
|
|
40
|
+
unlinkSync,
|
|
41
|
+
writeSync,
|
|
42
|
+
} from 'node:fs'
|
|
43
|
+
import { randomUUID } from 'node:crypto'
|
|
44
|
+
import { hostname } from 'node:os'
|
|
45
|
+
|
|
46
|
+
/** 锁文件后缀:`<gates.json>.lock`,和配置同目录(目录不存在时调用方先建)。 */
|
|
47
|
+
export const GATE_LOCK_SUFFIX = '.lock'
|
|
48
|
+
/** 守护锁后缀:`<gates.json>.lock.guard`,只护住「创建 / 接管 / 释放」那几微秒。 */
|
|
49
|
+
export const GATE_GUARD_SUFFIX = '.guard'
|
|
50
|
+
/** 等锁上限:超过就显式失败(写盘是毫秒级操作,5s 已经足够宽)。 */
|
|
51
|
+
export const DEFAULT_GATE_LOCK_TIMEOUT_MS = 5000
|
|
52
|
+
/** 等锁轮询间隔。 */
|
|
53
|
+
export const DEFAULT_GATE_LOCK_POLL_MS = 15
|
|
54
|
+
/** 超过这个年龄的锁视为 stale(持有者极可能已经崩在中间)。 */
|
|
55
|
+
export const DEFAULT_GATE_LOCK_STALE_MS = 30000
|
|
56
|
+
/** 守护锁只在崩溃时才会滞留;它本身只该活几微秒,所以 stale 阈值比锁小。 */
|
|
57
|
+
export const DEFAULT_GATE_GUARD_STALE_MS = 10000
|
|
58
|
+
/** rename 最多尝试次数(含第一次)。 */
|
|
59
|
+
export const DEFAULT_GATE_RENAME_ATTEMPTS = 6
|
|
60
|
+
/** rename 退避基数(线性:base × 第几次)。 */
|
|
61
|
+
export const DEFAULT_GATE_RENAME_BACKOFF_MS = 20
|
|
62
|
+
|
|
63
|
+
/** 只有这两个码值得重试:Windows 上目标文件被别的进程打开时的典型表现。 */
|
|
64
|
+
const RETRYABLE_RENAME_CODES = new Set(['EPERM', 'EBUSY'])
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 释放写锁时等守护锁的上限。守护锁只活微秒级,正常立刻拿到;给上限是为了**永不无限等**。
|
|
68
|
+
* 拿不到就不碰 lockPath(失败返回 false),宁可留着自己的锁,也不误删别人的新锁。
|
|
69
|
+
*/
|
|
70
|
+
const RELEASE_GUARD_TIMEOUT_MS = 2000
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 创建锁文件时代表「锁在别人手里」的错误码。
|
|
74
|
+
*
|
|
75
|
+
* `EEXIST` 是 POSIX 的常规答案;**Windows 上两个进程同时创建同一个文件时,`open(…, 'wx')`
|
|
76
|
+
* 会返回 EPERM / EBUSY(共享冲突),而不是 EEXIST**。把它当致命错误会让并发一高就整层写盘失败
|
|
77
|
+
* (2026-11-07 实测:两个真实进程各写 15 次,2/15 轮直接 EPERM 失败)。
|
|
78
|
+
*/
|
|
79
|
+
const CONTENDED_CREATE_CODES = new Set(['EEXIST', 'EPERM', 'EBUSY'])
|
|
80
|
+
|
|
81
|
+
/** 某个配置路径对应的锁路径。 */
|
|
82
|
+
export function gateLockPath(configPath) {
|
|
83
|
+
return String(configPath) + GATE_LOCK_SUFFIX
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 某个配置路径对应的守护锁路径。 */
|
|
87
|
+
export function gateGuardPath(configPath) {
|
|
88
|
+
return gateLockPath(configPath) + GATE_GUARD_SUFFIX
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 同步睡眠:`writeGateConfig` 是同步 API,等锁不能用 setTimeout。 */
|
|
92
|
+
export function sleepSync(ms) {
|
|
93
|
+
const wait = Number(ms)
|
|
94
|
+
if (!(wait > 0)) return
|
|
95
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, wait)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 读锁文件快照:年龄(按 mtime)+ 所有者(按内容)+ 原文。
|
|
100
|
+
* 原文用于「内容不可解析」时的身份比对(比如刚好崩在 open 与 write 之间)。
|
|
101
|
+
*/
|
|
102
|
+
function readLockState(lockPath) {
|
|
103
|
+
let stat
|
|
104
|
+
try {
|
|
105
|
+
stat = statSync(lockPath)
|
|
106
|
+
} catch {
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
let owner = null
|
|
110
|
+
let raw = ''
|
|
111
|
+
try {
|
|
112
|
+
raw = readFileSync(lockPath, 'utf8')
|
|
113
|
+
const parsed = JSON.parse(raw)
|
|
114
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) owner = parsed
|
|
115
|
+
} catch {
|
|
116
|
+
// 空 / 坏内容:所有者未知
|
|
117
|
+
}
|
|
118
|
+
// 年龄按 mtime 算;mtime 带亚毫秒精度,刚创建的锁可能出现 -0.x,钳到 0 避免「负年龄」。
|
|
119
|
+
return { path: lockPath, mtimeMs: stat.mtimeMs, ageMs: Math.max(0, Date.now() - stat.mtimeMs), owner, raw }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 进程是否还活着:ESRCH = 死了;EPERM 等「无权判断」按活着处理(宁可不抢锁)。 */
|
|
123
|
+
function isPidAlive(pid) {
|
|
124
|
+
const value = Number(pid)
|
|
125
|
+
if (!Number.isInteger(value) || value <= 0) return null
|
|
126
|
+
try {
|
|
127
|
+
process.kill(value, 0)
|
|
128
|
+
return true
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return error && error.code === 'ESRCH' ? false : true
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function describeOwner(owner) {
|
|
135
|
+
if (!owner) return '未知(锁文件内容不可解析)'
|
|
136
|
+
return 'pid=' + (owner.pid === undefined ? '?' : String(owner.pid))
|
|
137
|
+
+ ' host=' + String(owner.hostname || '?')
|
|
138
|
+
+ ' startedAt=' + String(owner.startedAt || '?')
|
|
139
|
+
+ (owner.token ? ' token=' + String(owner.token) : '')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 判定一把锁能不能接管:只有超龄、或同机所有者已死。活锁永远不抢。 */
|
|
143
|
+
function staleReason(state, staleMs, selfHost) {
|
|
144
|
+
if (!state) return null
|
|
145
|
+
if (state.ageMs >= staleMs) return 'age'
|
|
146
|
+
const owner = state.owner
|
|
147
|
+
if (!owner || typeof owner.hostname !== 'string' || owner.hostname !== selfHost) return null
|
|
148
|
+
return isPidAlive(owner.pid) === false ? 'owner_dead' : null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 把一个被搬错的文件原子放回原位:`link` 不覆盖已有文件,文件系统不支持时退回
|
|
153
|
+
* `copyFileSync(…, COPYFILE_EXCL)`(同样不覆盖)。返回是否还原成功。
|
|
154
|
+
*/
|
|
155
|
+
function restoreFile(from, to) {
|
|
156
|
+
try {
|
|
157
|
+
linkSync(from, to)
|
|
158
|
+
try {
|
|
159
|
+
unlinkSync(from)
|
|
160
|
+
} catch {
|
|
161
|
+
// 还原成功、残留清理失败:不影响锁语义
|
|
162
|
+
}
|
|
163
|
+
return true
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error && error.code === 'EEXIST') {
|
|
166
|
+
// 原位已经被新的锁占用:不覆盖它,放弃还原
|
|
167
|
+
try {
|
|
168
|
+
unlinkSync(from)
|
|
169
|
+
} catch {
|
|
170
|
+
// 忽略
|
|
171
|
+
}
|
|
172
|
+
return false
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
copyFileSync(from, to, fsConstants.COPYFILE_EXCL)
|
|
177
|
+
try {
|
|
178
|
+
unlinkSync(from)
|
|
179
|
+
} catch {
|
|
180
|
+
// 忽略
|
|
181
|
+
}
|
|
182
|
+
return true
|
|
183
|
+
} catch {
|
|
184
|
+
try {
|
|
185
|
+
unlinkSync(from)
|
|
186
|
+
} catch {
|
|
187
|
+
// 忽略
|
|
188
|
+
}
|
|
189
|
+
return false
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 接管 stale 锁并返回证据(年龄 + 所有者)。
|
|
195
|
+
*
|
|
196
|
+
* 先 rename 到唯一名字,**再核对搬走的是不是快照里那把**:
|
|
197
|
+
* - 是 → 删掉,接管成功;
|
|
198
|
+
* - 不是(别的写者刚创建了新锁)→ 原子放回去,返回 null 重新竞争。
|
|
199
|
+
* rename / unlink 本身没有「条件执行」,这一步核对 + 还原就是「不误删新锁」的兜底。
|
|
200
|
+
*/
|
|
201
|
+
function takeOverStale(targetPath, snapshot, reason) {
|
|
202
|
+
if (!snapshot) return null
|
|
203
|
+
const moved = targetPath + '.stale-' + Date.now() + '-' + randomUUID()
|
|
204
|
+
try {
|
|
205
|
+
renameSync(targetPath, moved)
|
|
206
|
+
} catch {
|
|
207
|
+
return null
|
|
208
|
+
}
|
|
209
|
+
let movedOwner = null
|
|
210
|
+
let movedRaw = ''
|
|
211
|
+
try {
|
|
212
|
+
movedRaw = readFileSync(moved, 'utf8')
|
|
213
|
+
const parsed = JSON.parse(movedRaw)
|
|
214
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) movedOwner = parsed
|
|
215
|
+
} catch {
|
|
216
|
+
// 内容不可解析:退化为原文比对
|
|
217
|
+
}
|
|
218
|
+
const expected = snapshot.owner ? String(snapshot.owner.token) : String(snapshot.raw || '')
|
|
219
|
+
const actual = movedOwner ? String(movedOwner.token) : movedRaw
|
|
220
|
+
if (expected !== actual) {
|
|
221
|
+
restoreFile(moved, targetPath)
|
|
222
|
+
return null
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
unlinkSync(moved)
|
|
226
|
+
} catch {
|
|
227
|
+
// 残留一个 .stale- 文件不影响正确性(它不会被当成锁)
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
path: targetPath,
|
|
231
|
+
reason,
|
|
232
|
+
ageMs: snapshot.ageMs,
|
|
233
|
+
owner: snapshot.owner,
|
|
234
|
+
brokenAt: new Date().toISOString(),
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function lockCreateError(lockPath, cause) {
|
|
239
|
+
const code = (cause && cause.code) || 'ELOCK'
|
|
240
|
+
const error = new Error(
|
|
241
|
+
'无法创建门禁配置写锁:' + lockPath + '(' + code + ':' + String((cause && cause.message) || cause)
|
|
242
|
+
+ ')。为避免并发覆盖,未在无锁状态下写入。',
|
|
243
|
+
)
|
|
244
|
+
error.code = code
|
|
245
|
+
error.lockPath = lockPath
|
|
246
|
+
error.cause = cause
|
|
247
|
+
return error
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function lockTimeoutError(lockPath, state, timeoutMs, label = '门禁配置写锁') {
|
|
251
|
+
const owner = state ? state.owner : null
|
|
252
|
+
const ageMs = state ? state.ageMs : null
|
|
253
|
+
const error = new Error(
|
|
254
|
+
'无法获取' + label + ':' + lockPath + '(等待 ' + timeoutMs + 'ms 超时;持有者 '
|
|
255
|
+
+ describeOwner(owner)
|
|
256
|
+
+ (ageMs === null ? '' : ',锁年龄 ' + ageMs + 'ms')
|
|
257
|
+
+ ')。为避免并发覆盖,未在无锁状态下写入。',
|
|
258
|
+
)
|
|
259
|
+
error.code = 'gate_lock_timeout'
|
|
260
|
+
error.lockPath = lockPath
|
|
261
|
+
error.timeoutMs = timeoutMs
|
|
262
|
+
error.owner = owner
|
|
263
|
+
error.ageMs = ageMs
|
|
264
|
+
return error
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** 锁已经不在自己手里(被判定 stale 接管过):宁可失败,也不覆盖别人的写盘。 */
|
|
268
|
+
function lockLostError(lockPath) {
|
|
269
|
+
const error = new Error(
|
|
270
|
+
'写锁已不在自己手里:' + lockPath
|
|
271
|
+
+ '(可能已被判定 stale 接管)。为避免覆盖别人的写盘,本次写入已放弃。',
|
|
272
|
+
)
|
|
273
|
+
error.code = 'gate_lock_lost'
|
|
274
|
+
error.lockPath = lockPath
|
|
275
|
+
return error
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 原子创建锁文件。
|
|
280
|
+
* @returns {{ok: true} | {ok: false, error: Error}} `ok:false` = 锁在别人手里(`error.code` 是竞争码)
|
|
281
|
+
*/
|
|
282
|
+
function tryCreateLockFile(lockPath, text, open) {
|
|
283
|
+
let fd
|
|
284
|
+
try {
|
|
285
|
+
fd = open(lockPath, 'wx', 0o600)
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (error && CONTENDED_CREATE_CODES.has(error.code)) return { ok: false, error }
|
|
288
|
+
throw lockCreateError(lockPath, error)
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
writeSync(fd, text)
|
|
292
|
+
} catch (error) {
|
|
293
|
+
try {
|
|
294
|
+
closeSync(fd)
|
|
295
|
+
} catch {
|
|
296
|
+
// 忽略:清理比关闭更重要
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
unlinkSync(lockPath)
|
|
300
|
+
} catch {
|
|
301
|
+
// 忽略:错误以原始创建失败为准
|
|
302
|
+
}
|
|
303
|
+
throw lockCreateError(lockPath, error)
|
|
304
|
+
}
|
|
305
|
+
closeSync(fd)
|
|
306
|
+
return { ok: true }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** 磁盘上的锁还是不是自己的(token 比对)。 */
|
|
310
|
+
function lockOwnedBy(lockPath, token) {
|
|
311
|
+
try {
|
|
312
|
+
const owner = JSON.parse(readFileSync(lockPath, 'utf8'))
|
|
313
|
+
return Boolean(owner && owner.token === token)
|
|
314
|
+
} catch {
|
|
315
|
+
return false
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* 只有 token 仍是自己的才删;不是自己的**一个字节都不动**。
|
|
321
|
+
* 调用者必须保证此刻没有别人在改同一个路径:写锁的释放包在守护区里,守护锁自己不需要再套一层。
|
|
322
|
+
*/
|
|
323
|
+
function unlinkIfOwned(path, token) {
|
|
324
|
+
if (!lockOwnedBy(path, token)) return false
|
|
325
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
326
|
+
try {
|
|
327
|
+
unlinkSync(path)
|
|
328
|
+
return true
|
|
329
|
+
} catch (error) {
|
|
330
|
+
if (!RETRYABLE_RENAME_CODES.has(error && error.code) || attempt === 3) return false
|
|
331
|
+
sleepSync(attempt * 5)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return false
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* 释放写锁:在守护区里「先核对 token,再从原位删除」。
|
|
339
|
+
*
|
|
340
|
+
* 关键(C3 / Q1R):**不能**先 rename 再核对。rename 是无条件操作 —— 旧持有者被挂起、
|
|
341
|
+
* 锁被判定 stale 接管、恢复后调 release 时,这么做会把**当前持有者的新锁**搬走,
|
|
342
|
+
* `lockPath` 出现空窗,第三写者能趁虚而入,两个写者同时进临界区。
|
|
343
|
+
* 现在 token 不匹配就原样返回 false,绝不碰 `lockPath`。
|
|
344
|
+
*
|
|
345
|
+
* 拿不到守护锁时同样选择「不碰 `lockPath`」:宁可留着自己的锁等它超龄,
|
|
346
|
+
* 也不冒误删别人新锁的风险(守护锁只活微秒级,正常不会拿不到)。
|
|
347
|
+
*/
|
|
348
|
+
function releaseLock(lockPath, token, { guardPath, guardStaleMs, timeoutMs, pollMs, sleep, open, selfHost }) {
|
|
349
|
+
let guard = null
|
|
350
|
+
try {
|
|
351
|
+
guard = acquireGuard(guardPath, {
|
|
352
|
+
timeoutMs: Math.min(timeoutMs, RELEASE_GUARD_TIMEOUT_MS),
|
|
353
|
+
pollMs,
|
|
354
|
+
staleMs: guardStaleMs,
|
|
355
|
+
sleep,
|
|
356
|
+
open,
|
|
357
|
+
selfHost,
|
|
358
|
+
})
|
|
359
|
+
} catch {
|
|
360
|
+
return false
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
return unlinkIfOwned(lockPath, token)
|
|
364
|
+
} finally {
|
|
365
|
+
guard.release()
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* 拿守护锁:只护住「创建 / 接管 / 释放」这几微秒,所以 stale 阈值单独给(默认 10s)。
|
|
371
|
+
* 活着的持有者永远不会被接管;崩在中间的守护锁按「所有者已死」立即接管。
|
|
372
|
+
*/
|
|
373
|
+
function acquireGuard(guardPath, { timeoutMs, pollMs, staleMs, sleep, open, selfHost }) {
|
|
374
|
+
const token = randomUUID()
|
|
375
|
+
const startedAtMs = Date.now()
|
|
376
|
+
const deadline = startedAtMs + Math.max(0, Number(timeoutMs) || 0)
|
|
377
|
+
const payload = JSON.stringify({
|
|
378
|
+
pid: process.pid,
|
|
379
|
+
hostname: selfHost,
|
|
380
|
+
startedAt: new Date(startedAtMs).toISOString(),
|
|
381
|
+
token,
|
|
382
|
+
kind: 'guard',
|
|
383
|
+
}) + '\n'
|
|
384
|
+
let state = null
|
|
385
|
+
let createError = null
|
|
386
|
+
while (true) {
|
|
387
|
+
const created = tryCreateLockFile(guardPath, payload, open)
|
|
388
|
+
if (created.ok) {
|
|
389
|
+
return {
|
|
390
|
+
path: guardPath,
|
|
391
|
+
token,
|
|
392
|
+
// 守护锁自己不再套一层守护锁:直接核对 token 再删(它只活微秒级,没人会在这一刻接管它)。
|
|
393
|
+
release() {
|
|
394
|
+
return unlinkIfOwned(guardPath, token)
|
|
395
|
+
},
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
createError = created.error
|
|
399
|
+
state = readLockState(guardPath)
|
|
400
|
+
if (staleReason(state, staleMs, selfHost)) {
|
|
401
|
+
if (takeOverStale(guardPath, state, 'guard')) continue
|
|
402
|
+
}
|
|
403
|
+
if (Date.now() >= deadline) {
|
|
404
|
+
if (!state) throw lockCreateError(guardPath, createError)
|
|
405
|
+
throw lockTimeoutError(guardPath, state, timeoutMs, '门禁配置写锁守护锁')
|
|
406
|
+
}
|
|
407
|
+
sleep(pollMs)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* 拿同一配置路径的跨进程写锁(同步、有界等待)。
|
|
413
|
+
*
|
|
414
|
+
* 拿不到就抛错(`code: 'gate_lock_timeout'` / 创建失败的原生码),**不会**返回一个「假锁」。
|
|
415
|
+
* stale 接管不静默:每次接管都进 `report.broken`,带年龄与所有者。
|
|
416
|
+
*
|
|
417
|
+
* 创建与接管都在 `<lock>.guard` 守护锁内完成:两个写者不会同时「看到同一把 stale 锁 → 各自搬走」,
|
|
418
|
+
* 也就不会把彼此刚建好的新锁搬走(C2/Q1 medium)。
|
|
419
|
+
*
|
|
420
|
+
* @param {string} configPath 配置文件的绝对路径(锁 = 该路径 + `.lock`,守护锁再加 `.guard`)
|
|
421
|
+
* @param {{timeoutMs?: number, pollMs?: number, staleMs?: number, guardStaleMs?: number, sleep?: (ms: number) => void, open?: typeof openSync}} [options]
|
|
422
|
+
* @returns {{path: string, configPath: string, token: string, waitedMs: number, broken: Array<object>, release: () => boolean, isOwner: () => boolean, assertOwner: () => true}}
|
|
423
|
+
*/
|
|
424
|
+
export function acquireGateConfigLock(configPath, {
|
|
425
|
+
timeoutMs = DEFAULT_GATE_LOCK_TIMEOUT_MS,
|
|
426
|
+
pollMs = DEFAULT_GATE_LOCK_POLL_MS,
|
|
427
|
+
staleMs = DEFAULT_GATE_LOCK_STALE_MS,
|
|
428
|
+
guardStaleMs = DEFAULT_GATE_GUARD_STALE_MS,
|
|
429
|
+
sleep = sleepSync,
|
|
430
|
+
// 测试注入点:证明「并发创建拿到 EPERM」按竞争等待,而不是直接失败。
|
|
431
|
+
open = openSync,
|
|
432
|
+
} = {}) {
|
|
433
|
+
const lockPath = gateLockPath(configPath)
|
|
434
|
+
const guardPath = gateGuardPath(configPath)
|
|
435
|
+
const token = randomUUID()
|
|
436
|
+
const selfHost = hostname()
|
|
437
|
+
const startedAtMs = Date.now()
|
|
438
|
+
const timeout = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) >= 0
|
|
439
|
+
? Number(timeoutMs)
|
|
440
|
+
: DEFAULT_GATE_LOCK_TIMEOUT_MS
|
|
441
|
+
const poll = Number.isFinite(Number(pollMs)) && Number(pollMs) > 0 ? Number(pollMs) : DEFAULT_GATE_LOCK_POLL_MS
|
|
442
|
+
// 非法 staleMs(0 / 负数 / NaN)退回默认值:否则「所有锁都算 stale」会把活锁也抢掉。
|
|
443
|
+
const stale = Number.isFinite(Number(staleMs)) && Number(staleMs) > 0 ? Number(staleMs) : DEFAULT_GATE_LOCK_STALE_MS
|
|
444
|
+
const guardStale = Number.isFinite(Number(guardStaleMs)) && Number(guardStaleMs) > 0
|
|
445
|
+
? Number(guardStaleMs)
|
|
446
|
+
: DEFAULT_GATE_GUARD_STALE_MS
|
|
447
|
+
const deadline = startedAtMs + timeout
|
|
448
|
+
const broken = []
|
|
449
|
+
let state = null
|
|
450
|
+
let createError = null
|
|
451
|
+
const payload = JSON.stringify({
|
|
452
|
+
pid: process.pid,
|
|
453
|
+
hostname: selfHost,
|
|
454
|
+
startedAt: new Date(startedAtMs).toISOString(),
|
|
455
|
+
token,
|
|
456
|
+
configPath,
|
|
457
|
+
}) + '\n'
|
|
458
|
+
|
|
459
|
+
const handle = () => ({
|
|
460
|
+
path: lockPath,
|
|
461
|
+
configPath,
|
|
462
|
+
token,
|
|
463
|
+
waitedMs: Date.now() - startedAtMs,
|
|
464
|
+
broken,
|
|
465
|
+
release() {
|
|
466
|
+
// 释放同样走守护区:先核对 token 再删,token 不匹配就一个字节都不碰(C3)。
|
|
467
|
+
return releaseLock(lockPath, token, {
|
|
468
|
+
guardPath,
|
|
469
|
+
guardStaleMs: guardStale,
|
|
470
|
+
timeoutMs: timeout,
|
|
471
|
+
pollMs: poll,
|
|
472
|
+
sleep,
|
|
473
|
+
open,
|
|
474
|
+
selfHost,
|
|
475
|
+
})
|
|
476
|
+
},
|
|
477
|
+
isOwner() {
|
|
478
|
+
return lockOwnedBy(lockPath, token)
|
|
479
|
+
},
|
|
480
|
+
assertOwner() {
|
|
481
|
+
if (lockOwnedBy(lockPath, token)) return true
|
|
482
|
+
throw lockLostError(lockPath)
|
|
483
|
+
},
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
while (true) {
|
|
487
|
+
// 快路径:锁在、且没超龄 / 所有者没死 —— 只等,不碰守护锁。
|
|
488
|
+
// 这一步不做任何 create / rename / unlink,所以跳过互斥是安全的;真正会动 lockPath 的
|
|
489
|
+
// 动作(创建 / 接管 / 释放)一律走下面的守护区。
|
|
490
|
+
const snapshot = readLockState(lockPath)
|
|
491
|
+
if (snapshot && !staleReason(snapshot, stale, selfHost)) {
|
|
492
|
+
state = snapshot
|
|
493
|
+
if (Date.now() >= deadline) throw lockTimeoutError(lockPath, state, timeout)
|
|
494
|
+
sleep(poll)
|
|
495
|
+
continue
|
|
496
|
+
}
|
|
497
|
+
// 守护区:创建 / 接管是**互斥**的,不会有两个写者同时搬同一把 stale 锁。
|
|
498
|
+
let guard = null
|
|
499
|
+
try {
|
|
500
|
+
guard = acquireGuard(guardPath, {
|
|
501
|
+
timeoutMs: Math.max(0, deadline - Date.now()),
|
|
502
|
+
pollMs: poll,
|
|
503
|
+
staleMs: guardStale,
|
|
504
|
+
sleep,
|
|
505
|
+
open,
|
|
506
|
+
selfHost,
|
|
507
|
+
})
|
|
508
|
+
} catch (error) {
|
|
509
|
+
// 守护锁都拿不到:如果已经见过真锁,按「等锁超时」报(带持有者证据);否则原样报创建错误。
|
|
510
|
+
if (state) throw lockTimeoutError(lockPath, state, timeout)
|
|
511
|
+
throw error
|
|
512
|
+
}
|
|
513
|
+
try {
|
|
514
|
+
const created = tryCreateLockFile(lockPath, payload, open)
|
|
515
|
+
if (created.ok) return handle()
|
|
516
|
+
createError = created.error
|
|
517
|
+
state = readLockState(lockPath)
|
|
518
|
+
const reason = staleReason(state, stale, selfHost)
|
|
519
|
+
if (reason) {
|
|
520
|
+
const evidence = takeOverStale(lockPath, state, reason)
|
|
521
|
+
if (evidence) {
|
|
522
|
+
broken.push(evidence)
|
|
523
|
+
// 接管成功后立刻(仍在守护区里)创建自己的锁,不给别人插队的窗口。
|
|
524
|
+
const again = tryCreateLockFile(lockPath, payload, open)
|
|
525
|
+
if (again.ok) return handle()
|
|
526
|
+
createError = again.error
|
|
527
|
+
state = readLockState(lockPath)
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
} finally {
|
|
531
|
+
if (guard) guard.release()
|
|
532
|
+
}
|
|
533
|
+
if (Date.now() >= deadline) {
|
|
534
|
+
// 锁文件从头到尾没出现过:不是「有人持有」,而是创建本身失败(只读目录 / 权限),
|
|
535
|
+
// 报原始错误码;只有真看到锁时才报超时 + 持有者证据。
|
|
536
|
+
if (!state) throw lockCreateError(lockPath, createError)
|
|
537
|
+
throw lockTimeoutError(lockPath, state, timeout)
|
|
538
|
+
}
|
|
539
|
+
sleep(poll)
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* 原子替换 + Windows EPERM / EBUSY 有界退避重试。
|
|
545
|
+
*
|
|
546
|
+
* 只对 EPERM / EBUSY 重试(目标文件被占用);其余错误立即失败。
|
|
547
|
+
* 最终失败抛出的错误带 `code`(原始错误码)、`path`、`tmpPath`、`attempts`,原文件不动。
|
|
548
|
+
*
|
|
549
|
+
* @param {string} from 临时文件
|
|
550
|
+
* @param {string} to 目标文件
|
|
551
|
+
* @param {{attempts?: number, backoffMs?: number, rename?: (from: string, to: string) => void, sleep?: (ms: number) => void}} [options]
|
|
552
|
+
* @returns {{attempts: number, from: string, to: string}}
|
|
553
|
+
*/
|
|
554
|
+
export function renameWithRetry(from, to, {
|
|
555
|
+
attempts = DEFAULT_GATE_RENAME_ATTEMPTS,
|
|
556
|
+
backoffMs = DEFAULT_GATE_RENAME_BACKOFF_MS,
|
|
557
|
+
rename = renameSync,
|
|
558
|
+
sleep = sleepSync,
|
|
559
|
+
} = {}) {
|
|
560
|
+
const total = Math.max(1, Math.trunc(Number(attempts)) || 1)
|
|
561
|
+
const backoff = Number.isFinite(Number(backoffMs)) && Number(backoffMs) > 0 ? Number(backoffMs) : 0
|
|
562
|
+
let lastError = null
|
|
563
|
+
let tried = 0
|
|
564
|
+
for (let attempt = 1; attempt <= total; attempt += 1) {
|
|
565
|
+
tried = attempt
|
|
566
|
+
try {
|
|
567
|
+
rename(from, to)
|
|
568
|
+
return { attempts: attempt, from, to }
|
|
569
|
+
} catch (error) {
|
|
570
|
+
lastError = error
|
|
571
|
+
const code = error && error.code
|
|
572
|
+
if (!RETRYABLE_RENAME_CODES.has(code) || attempt === total) break
|
|
573
|
+
// 线性有界退避(20ms / 40ms / …):总等待远小于一次用户可感知的保存。
|
|
574
|
+
sleep(backoff * attempt)
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const code = (lastError && lastError.code) || 'ERENAME'
|
|
578
|
+
const error = new Error(
|
|
579
|
+
'无法原子替换门禁配置:' + to + '(' + code + ':' + String((lastError && lastError.message) || lastError)
|
|
580
|
+
+ ';已尝试 ' + tried + ' 次)。原文件保持不变。',
|
|
581
|
+
)
|
|
582
|
+
error.code = code
|
|
583
|
+
error.path = to
|
|
584
|
+
error.tmpPath = from
|
|
585
|
+
error.attempts = tried
|
|
586
|
+
error.cause = lastError
|
|
587
|
+
throw error
|
|
588
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -28,12 +28,38 @@ import {
|
|
|
28
28
|
import { homedir } from 'node:os'
|
|
29
29
|
import { basename, dirname, join, resolve } from 'node:path'
|
|
30
30
|
import { fileURLToPath } from 'node:url'
|
|
31
|
+
import {
|
|
32
|
+
DEFAULT_GATE_GUARD_STALE_MS,
|
|
33
|
+
DEFAULT_GATE_LOCK_POLL_MS,
|
|
34
|
+
DEFAULT_GATE_LOCK_STALE_MS,
|
|
35
|
+
DEFAULT_GATE_LOCK_TIMEOUT_MS,
|
|
36
|
+
DEFAULT_GATE_RENAME_ATTEMPTS,
|
|
37
|
+
DEFAULT_GATE_RENAME_BACKOFF_MS,
|
|
38
|
+
acquireGateConfigLock,
|
|
39
|
+
renameWithRetry,
|
|
40
|
+
sleepSync,
|
|
41
|
+
} from './config-lock.js'
|
|
31
42
|
import {
|
|
32
43
|
SEMANTIC_TRIGGERS,
|
|
33
44
|
normalizeSemanticConfig,
|
|
34
45
|
semanticReview,
|
|
35
46
|
} from './semantic.js'
|
|
36
47
|
|
|
48
|
+
export {
|
|
49
|
+
DEFAULT_GATE_GUARD_STALE_MS,
|
|
50
|
+
DEFAULT_GATE_LOCK_POLL_MS,
|
|
51
|
+
DEFAULT_GATE_LOCK_STALE_MS,
|
|
52
|
+
DEFAULT_GATE_LOCK_TIMEOUT_MS,
|
|
53
|
+
DEFAULT_GATE_RENAME_ATTEMPTS,
|
|
54
|
+
DEFAULT_GATE_RENAME_BACKOFF_MS,
|
|
55
|
+
GATE_GUARD_SUFFIX,
|
|
56
|
+
GATE_LOCK_SUFFIX,
|
|
57
|
+
acquireGateConfigLock,
|
|
58
|
+
gateGuardPath,
|
|
59
|
+
gateLockPath,
|
|
60
|
+
renameWithRetry,
|
|
61
|
+
} from './config-lock.js'
|
|
62
|
+
|
|
37
63
|
export {
|
|
38
64
|
SEMANTIC_TRIGGERS,
|
|
39
65
|
listModels,
|
|
@@ -46,7 +72,7 @@ export {
|
|
|
46
72
|
|
|
47
73
|
export const PACKAGE_NAME = '@mycf711/dsh-gate'
|
|
48
74
|
/** 与 package.json 的 version 同步(测试守着)。 */
|
|
49
|
-
export const PACKAGE_VERSION = '0.1.
|
|
75
|
+
export const PACKAGE_VERSION = '0.1.3'
|
|
50
76
|
|
|
51
77
|
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
52
78
|
|
|
@@ -536,55 +562,92 @@ export function readGateScope(scope, { repo = process.cwd(), home = homedir(), e
|
|
|
536
562
|
* - 入参 `extends: true | 'default' | '<路径>' | {…}`:抛错(含配置路径与解决方式);
|
|
537
563
|
* - 文件里已有这类共享 `extends`:不管入参写什么(包括 `false`)都抛错,绝不静默冲掉基规则;
|
|
538
564
|
* - 文件里已有 `false | 'none'` 而入参没写:原样保留,避免「替换」悄悄退化成「合并」。
|
|
565
|
+
*
|
|
566
|
+
* 并发(C1):整段「读旧文件 → 校验 → 备份 → 临时写入 → rename」在同一路径的跨进程写锁内串行,
|
|
567
|
+
* 两个写者不会拿着旧快照互相覆盖;拿不到锁显式失败(`code: 'gate_lock_timeout'`),**不**退回无锁写。
|
|
568
|
+
* Windows 上目标文件被占用时 rename 会 EPERM / EBUSY:`renameAttempts` 次有界退避重试,
|
|
569
|
+
* 最终失败保留原文件、清理临时文件。
|
|
570
|
+
* 接管 stale 锁的段由守护锁串行、搬走后核对 token 并还原(C2),写盘前还会再确认一次锁仍是自己的:
|
|
571
|
+
* 锁被接管过就抛 `code: 'gate_lock_lost'`,绝不覆盖别人的写盘;释放同样在守护区里先核对 token
|
|
572
|
+
* 再从原位删(C3),token 不匹配就一个字节都不碰。
|
|
573
|
+
*
|
|
574
|
+
* @returns {{ path: string, config: object, lock: { path: string, waitedMs: number, broken: Array<object> } }}
|
|
575
|
+
* `lock.broken` 是本次接管过的 stale 锁证据(年龄 + 所有者),没有接管就是空数组。
|
|
539
576
|
*/
|
|
540
|
-
export function writeGateConfig(scope, config, {
|
|
577
|
+
export function writeGateConfig(scope, config, {
|
|
578
|
+
repo = process.cwd(),
|
|
579
|
+
home = homedir(),
|
|
580
|
+
env = process.env,
|
|
581
|
+
lockTimeoutMs = DEFAULT_GATE_LOCK_TIMEOUT_MS,
|
|
582
|
+
lockPollMs = DEFAULT_GATE_LOCK_POLL_MS,
|
|
583
|
+
lockStaleMs = DEFAULT_GATE_LOCK_STALE_MS,
|
|
584
|
+
lockGuardStaleMs = DEFAULT_GATE_GUARD_STALE_MS,
|
|
585
|
+
renameAttempts = DEFAULT_GATE_RENAME_ATTEMPTS,
|
|
586
|
+
renameBackoffMs = DEFAULT_GATE_RENAME_BACKOFF_MS,
|
|
587
|
+
// 测试注入点:Windows 上真实的 EPERM 无法稳定制造,用替身证明退避重试与失败清理。
|
|
588
|
+
rename = renameSync,
|
|
589
|
+
sleep = sleepSync,
|
|
590
|
+
} = {}) {
|
|
541
591
|
if (scope !== 'user' && scope !== 'project' && scope !== 'local') {
|
|
542
592
|
throw new Error('scope 只能是 user / project / local')
|
|
543
593
|
}
|
|
544
594
|
const path = configPathFor(scope, { repo, home, env })
|
|
545
|
-
const input = config && typeof config === 'object' && !Array.isArray(config) ? config : {}
|
|
546
|
-
// 1) 调用方传入的 extends:能原样保留的写回,其余显式报错。
|
|
547
|
-
const incoming = hasOwn(input, 'extends') ? classifyExtends(input.extends) : EXTENDS_ABSENT
|
|
548
|
-
if (incoming === EXTENDS_SHARED || incoming === EXTENDS_INVALID) {
|
|
549
|
-
throw extendsWriteError('incoming', path, input.extends)
|
|
550
|
-
}
|
|
551
|
-
// 2) 磁盘上已有的 extends:整层重写会把它冲掉,同样不能无提示改写。
|
|
552
|
-
const existing = existingExtends(path)
|
|
553
|
-
if (existing.kind === EXTENDS_SHARED || existing.kind === EXTENDS_INVALID) {
|
|
554
|
-
throw extendsWriteError('existing', path, existing.value)
|
|
555
|
-
}
|
|
556
|
-
// 3) 可安全保留的值:入参优先;入参没写就用文件里已有的(否则等于悄悄改了语义)。
|
|
557
|
-
const keepExtends = incoming === EXTENDS_KEEP
|
|
558
|
-
? { value: input.extends }
|
|
559
|
-
: existing.kind === EXTENDS_KEEP
|
|
560
|
-
? { value: existing.value }
|
|
561
|
-
: null
|
|
562
|
-
const checked = validateConfig(config)
|
|
563
|
-
if (!checked.ok) throw new Error(checked.error)
|
|
564
|
-
const normalized = checked.config
|
|
565
|
-
// 明确的字段顺序:extends 跟在 name 后面,读文件的人一眼能看到这一层挂在哪。
|
|
566
|
-
const output = {
|
|
567
|
-
version: normalized.version,
|
|
568
|
-
name: normalized.name,
|
|
569
|
-
...(keepExtends ? { extends: keepExtends.value } : {}),
|
|
570
|
-
rules: normalized.rules,
|
|
571
|
-
semantic: normalized.semantic,
|
|
572
|
-
}
|
|
573
595
|
mkdirSync(dirname(path), { recursive: true })
|
|
574
|
-
const
|
|
596
|
+
const lock = acquireGateConfigLock(path, {
|
|
597
|
+
timeoutMs: lockTimeoutMs,
|
|
598
|
+
pollMs: lockPollMs,
|
|
599
|
+
staleMs: lockStaleMs,
|
|
600
|
+
guardStaleMs: lockGuardStaleMs,
|
|
601
|
+
sleep,
|
|
602
|
+
})
|
|
575
603
|
try {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
604
|
+
const input = config && typeof config === 'object' && !Array.isArray(config) ? config : {}
|
|
605
|
+
// 1) 调用方传入的 extends:能原样保留的写回,其余显式报错。
|
|
606
|
+
const incoming = hasOwn(input, 'extends') ? classifyExtends(input.extends) : EXTENDS_ABSENT
|
|
607
|
+
if (incoming === EXTENDS_SHARED || incoming === EXTENDS_INVALID) {
|
|
608
|
+
throw extendsWriteError('incoming', path, input.extends)
|
|
609
|
+
}
|
|
610
|
+
// 2) 磁盘上已有的 extends:整层重写会把它冲掉,同样不能无提示改写。
|
|
611
|
+
const existing = existingExtends(path)
|
|
612
|
+
if (existing.kind === EXTENDS_SHARED || existing.kind === EXTENDS_INVALID) {
|
|
613
|
+
throw extendsWriteError('existing', path, existing.value)
|
|
614
|
+
}
|
|
615
|
+
// 3) 可安全保留的值:入参优先;入参没写就用文件里已有的(否则等于悄悄改了语义)。
|
|
616
|
+
const keepExtends = incoming === EXTENDS_KEEP
|
|
617
|
+
? { value: input.extends }
|
|
618
|
+
: existing.kind === EXTENDS_KEEP
|
|
619
|
+
? { value: existing.value }
|
|
620
|
+
: null
|
|
621
|
+
const checked = validateConfig(config)
|
|
622
|
+
if (!checked.ok) throw new Error(checked.error)
|
|
623
|
+
const normalized = checked.config
|
|
624
|
+
// 明确的字段顺序:extends 跟在 name 后面,读文件的人一眼能看到这一层挂在哪。
|
|
625
|
+
const output = {
|
|
626
|
+
version: normalized.version,
|
|
627
|
+
name: normalized.name,
|
|
628
|
+
...(keepExtends ? { extends: keepExtends.value } : {}),
|
|
629
|
+
rules: normalized.rules,
|
|
630
|
+
semantic: normalized.semantic,
|
|
631
|
+
}
|
|
632
|
+
const tmp = path + '.tmp-' + process.pid + '-' + Date.now()
|
|
580
633
|
try {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
634
|
+
// C2:写盘前再确认锁还在自己手里。锁被判定 stale 接管过时,宁可失败也不覆盖别人的写盘。
|
|
635
|
+
lock.assertOwner()
|
|
636
|
+
if (existsSync(path)) copyFileSync(path, path + '.bak')
|
|
637
|
+
writeFileSync(tmp, JSON.stringify(output, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
638
|
+
renameWithRetry(tmp, path, { attempts: renameAttempts, backoffMs: renameBackoffMs, rename, sleep })
|
|
639
|
+
} catch (error) {
|
|
640
|
+
try {
|
|
641
|
+
if (existsSync(tmp)) unlinkSync(tmp)
|
|
642
|
+
} catch {
|
|
643
|
+
// 清理失败不覆盖原始错误
|
|
644
|
+
}
|
|
645
|
+
throw error
|
|
584
646
|
}
|
|
585
|
-
|
|
647
|
+
return { path, config: output, lock: { path: lock.path, waitedMs: lock.waitedMs, broken: lock.broken } }
|
|
648
|
+
} finally {
|
|
649
|
+
lock.release()
|
|
586
650
|
}
|
|
587
|
-
return { path, config: output }
|
|
588
651
|
}
|
|
589
652
|
|
|
590
653
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mycf711/dsh-gate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "内容层门禁:四层配置、确定性规则与可选语义复审,可作为 DSH 插件依赖独立安装。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"./semantic": "./lib/semantic.js",
|
|
10
10
|
"./redact": "./lib/redact.js",
|
|
11
11
|
"./lib/index.js": "./lib/index.js",
|
|
12
|
+
"./lib/config-lock.js": "./lib/config-lock.js",
|
|
12
13
|
"./lib/semantic.js": "./lib/semantic.js",
|
|
13
14
|
"./lib/redact.js": "./lib/redact.js",
|
|
14
15
|
"./gates/default.json": "./gates/default.json",
|
|
@@ -29,7 +30,7 @@
|
|
|
29
30
|
},
|
|
30
31
|
"scripts": {
|
|
31
32
|
"test": "node --test",
|
|
32
|
-
"check": "node --check lib/index.js && node --check lib/semantic.js && node --check lib/redact.js && node --check bin/gate.mjs"
|
|
33
|
+
"check": "node --check lib/index.js && node --check lib/config-lock.js && node --check lib/semantic.js && node --check lib/redact.js && node --check bin/gate.mjs"
|
|
33
34
|
},
|
|
34
35
|
"keywords": [
|
|
35
36
|
"dsh",
|