@actiondock/core 2.5.0 → 2.6.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.
Files changed (134) hide show
  1. package/dist/app/app.d.ts +14 -1
  2. package/dist/app/app.js +88 -215
  3. package/dist/app/static-index.d.ts +39 -0
  4. package/dist/app/static-index.js +164 -0
  5. package/dist/app/types.d.ts +5 -0
  6. package/dist/catalog/action-index.js +6 -4
  7. package/dist/catalog/package-catalog.js +2 -1
  8. package/dist/doctor/checks/index.d.ts +5 -0
  9. package/dist/doctor/checks/index.js +5 -0
  10. package/dist/doctor/checks/project-locate.d.ts +6 -0
  11. package/dist/doctor/checks/project-locate.js +36 -0
  12. package/dist/doctor/checks/project.d.ts +6 -0
  13. package/dist/doctor/checks/project.js +383 -0
  14. package/dist/doctor/checks/registry.d.ts +13 -0
  15. package/dist/doctor/checks/registry.js +207 -0
  16. package/dist/doctor/checks/runtime.d.ts +13 -0
  17. package/dist/doctor/checks/runtime.js +90 -0
  18. package/dist/doctor/checks/storage.d.ts +5 -0
  19. package/dist/doctor/checks/storage.js +34 -0
  20. package/dist/doctor/context.d.ts +35 -0
  21. package/dist/doctor/context.js +11 -0
  22. package/dist/doctor/doctor.d.ts +5 -0
  23. package/dist/doctor/doctor.js +33 -602
  24. package/dist/errors.d.ts +2 -0
  25. package/dist/errors.js +2 -0
  26. package/dist/execution/service.js +14 -9
  27. package/dist/host/host.d.ts +33 -0
  28. package/dist/host/host.js +222 -362
  29. package/dist/host/routing.d.ts +92 -0
  30. package/dist/host/routing.js +238 -0
  31. package/dist/host/types.d.ts +8 -0
  32. package/dist/ipc/host.js +38 -0
  33. package/dist/platform/types.d.ts +7 -0
  34. package/dist/process/control-arbiter.d.ts +107 -0
  35. package/dist/process/control-arbiter.js +251 -0
  36. package/dist/process/diagnostics-sink.d.ts +26 -0
  37. package/dist/process/diagnostics-sink.js +35 -0
  38. package/dist/process/index.d.ts +7 -0
  39. package/dist/process/index.js +7 -0
  40. package/dist/process/input-dispatcher.d.ts +88 -0
  41. package/dist/process/input-dispatcher.js +240 -0
  42. package/dist/process/managed-record.d.ts +78 -0
  43. package/dist/process/managed-record.js +1 -0
  44. package/dist/process/process-manager.d.ts +15 -56
  45. package/dist/process/process-manager.js +119 -707
  46. package/dist/process/reservation-table.d.ts +39 -0
  47. package/dist/process/reservation-table.js +80 -0
  48. package/dist/process/run-executor.d.ts +16 -0
  49. package/dist/process/run-executor.js +155 -0
  50. package/dist/process/terminal-output-cache.d.ts +66 -0
  51. package/dist/process/terminal-output-cache.js +132 -0
  52. package/dist/profile/client-actions.d.ts +56 -0
  53. package/dist/profile/client-actions.js +215 -0
  54. package/dist/profile/client-config.d.ts +20 -0
  55. package/dist/profile/client-config.js +51 -0
  56. package/dist/profile/client-health.d.ts +14 -0
  57. package/dist/profile/client-health.js +68 -0
  58. package/dist/profile/client-playbooks.d.ts +19 -0
  59. package/dist/profile/client-playbooks.js +36 -0
  60. package/dist/profile/client-query.d.ts +30 -0
  61. package/dist/profile/client-query.js +70 -0
  62. package/dist/profile/client-runs.d.ts +42 -0
  63. package/dist/profile/client-runs.js +58 -0
  64. package/dist/profile/client-state.d.ts +56 -0
  65. package/dist/profile/client-state.js +83 -0
  66. package/dist/profile/client-transport.d.ts +61 -0
  67. package/dist/profile/client-transport.js +155 -0
  68. package/dist/profile/client.d.ts +23 -142
  69. package/dist/profile/client.js +28 -588
  70. package/dist/profile/manager.js +1 -1
  71. package/dist/project/closure.d.ts +4 -4
  72. package/dist/project/closure.js +11 -5
  73. package/dist/project/init.js +2 -2
  74. package/dist/project/loader.d.ts +0 -25
  75. package/dist/project/loader.js +1 -154
  76. package/dist/project/transactions.d.ts +12 -14
  77. package/dist/project/transactions.js +60 -836
  78. package/dist/registry/registry.js +6 -4
  79. package/dist/registry/resolve.js +1 -1
  80. package/dist/runtime/action-registry.d.ts +45 -0
  81. package/dist/runtime/action-registry.js +108 -0
  82. package/dist/runtime/clock.d.ts +5 -17
  83. package/dist/runtime/clock.js +5 -15
  84. package/dist/runtime/context.d.ts +3 -0
  85. package/dist/runtime/context.js +17 -56
  86. package/dist/runtime/index.d.ts +3 -0
  87. package/dist/runtime/index.js +3 -0
  88. package/dist/runtime/package-runner-factory.d.ts +82 -0
  89. package/dist/runtime/package-runner-factory.js +186 -0
  90. package/dist/runtime/run-persistence.d.ts +85 -0
  91. package/dist/runtime/run-persistence.js +111 -0
  92. package/dist/runtime/runner.d.ts +23 -33
  93. package/dist/runtime/runner.js +92 -286
  94. package/dist/runtime/standalone.d.ts +11 -1
  95. package/dist/runtime/standalone.js +137 -25
  96. package/dist/server/dispatcher.d.ts +8 -3
  97. package/dist/server/dispatcher.js +16 -40
  98. package/dist/server/routes/runs.js +6 -4
  99. package/dist/server/security.d.ts +1 -7
  100. package/dist/server/security.js +2 -13
  101. package/dist/storage/clock.d.ts +25 -0
  102. package/dist/storage/clock.js +23 -0
  103. package/dist/storage/data-dir-lock.d.ts +3 -36
  104. package/dist/storage/data-dir-lock.js +48 -881
  105. package/dist/storage/index.d.ts +8 -1
  106. package/dist/storage/index.js +8 -2
  107. package/dist/storage/lazy.js +1 -0
  108. package/dist/storage/lock-core.d.ts +162 -0
  109. package/dist/storage/lock-core.js +885 -0
  110. package/dist/storage/sqlite.d.ts +19 -6
  111. package/dist/storage/sqlite.js +81 -89
  112. package/dist/storage/types.d.ts +27 -5
  113. package/dist/storage/types.js +21 -0
  114. package/dist/storage/utils.d.ts +22 -0
  115. package/dist/storage/utils.js +34 -0
  116. package/dist/target/local.d.ts +1 -0
  117. package/dist/target/local.js +23 -17
  118. package/dist/target/remote-errors.d.ts +21 -0
  119. package/dist/target/remote-errors.js +51 -0
  120. package/dist/target/remote-polling.d.ts +35 -0
  121. package/dist/target/remote-polling.js +104 -0
  122. package/dist/target/remote.d.ts +4 -0
  123. package/dist/target/remote.js +108 -301
  124. package/dist/target/sse-parser.d.ts +35 -0
  125. package/dist/target/sse-parser.js +163 -0
  126. package/dist/target/target.js +2 -0
  127. package/dist/target/types.d.ts +6 -0
  128. package/dist/utils/index.d.ts +1 -0
  129. package/dist/utils/index.js +1 -0
  130. package/dist/utils/net.d.ts +10 -0
  131. package/dist/utils/net.js +16 -0
  132. package/dist/version.d.ts +1 -1
  133. package/dist/version.js +1 -1
  134. package/package.json +2 -2
@@ -0,0 +1,885 @@
1
+ import fs, { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ /**
5
+ * 目录锁内核(单一事实源)。
6
+ *
7
+ * 统一承载工程修改锁(project.lock)与数据目录排他锁(.actiondock.data.lock)
8
+ * 共用的目录锁协议实现:原子目录创建、元数据宽限期检测、接管守卫(reclaim guard)
9
+ * 仲裁、检疫隔离与孤儿回收。上层模块(project/transactions 与 storage/data-dir-lock)
10
+ * 仅以参数与钩子形式注入领域差异(路径、错误码、消息与陈旧持有者判定),严禁在此
11
+ * 之外再复制锁算法实现。
12
+ *
13
+ * 物理布局约定:主锁为目录,内部存放 metadata.json;接管守卫目录为主锁路径加
14
+ * .reclaim 后缀;隔离与孤儿目录以 .quarantine / .rollback / .release / .orphan
15
+ * 后缀标记并由 GC 按时间与存活凭证回收。
16
+ */
17
+ const METADATA_FILE = "metadata.json";
18
+ const RECLAIM_SUFFIX = ".reclaim";
19
+ /**
20
+ * 检查目标进程是否处于存活状态。
21
+ *
22
+ * @param pid 待检测的进程标识符
23
+ */
24
+ export function isProcessAlive(pid) {
25
+ if (typeof pid !== "number" || isNaN(pid) || pid <= 0) {
26
+ return false;
27
+ }
28
+ try {
29
+ process.kill(pid, 0);
30
+ return true;
31
+ }
32
+ catch (err) {
33
+ return Boolean(err && err.code === "EPERM");
34
+ }
35
+ }
36
+ /**
37
+ * 同步休眠指定毫秒数。
38
+ */
39
+ function sleepSync(ms) {
40
+ try {
41
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
42
+ }
43
+ catch {
44
+ const end = Date.now() + ms;
45
+ while (Date.now() < end) {
46
+ // 降级忙等待
47
+ }
48
+ }
49
+ }
50
+ /**
51
+ * 获取排他锁路径最近一次修改时间戳。
52
+ */
53
+ function getLockMtimeMs(lockPath, isDir) {
54
+ try {
55
+ const stat = statSync(lockPath);
56
+ let mtime = Math.max(stat.mtimeMs, stat.ctimeMs ?? 0);
57
+ if (isDir) {
58
+ const metaPath = join(lockPath, METADATA_FILE);
59
+ if (existsSync(metaPath)) {
60
+ const metaStat = statSync(metaPath);
61
+ mtime = Math.max(mtime, metaStat.mtimeMs, metaStat.ctimeMs ?? 0);
62
+ }
63
+ }
64
+ return mtime;
65
+ }
66
+ catch {
67
+ return Date.now();
68
+ }
69
+ }
70
+ /**
71
+ * 读取排他锁元数据并执行宽限期检测。
72
+ */
73
+ export function readLockWithGracePeriod(lockPath, gracePeriodMs = 3000, deadline) {
74
+ if (!existsSync(lockPath)) {
75
+ return { exists: false, isDir: false, inGracePeriod: false };
76
+ }
77
+ let isDir = false;
78
+ try {
79
+ isDir = statSync(lockPath).isDirectory();
80
+ }
81
+ catch {
82
+ return { exists: false, isDir: false, inGracePeriod: false };
83
+ }
84
+ const metaPath = isDir ? join(lockPath, METADATA_FILE) : lockPath;
85
+ const tryParse = () => {
86
+ try {
87
+ if (existsSync(metaPath)) {
88
+ const raw = readFileSync(metaPath, "utf8");
89
+ if (raw.trim().length > 0) {
90
+ const parsed = JSON.parse(raw);
91
+ if (parsed && typeof parsed.pid === "number") {
92
+ return parsed;
93
+ }
94
+ }
95
+ }
96
+ }
97
+ catch {
98
+ // 损坏或并发写入中
99
+ }
100
+ return undefined;
101
+ };
102
+ const initialInfo = tryParse();
103
+ if (initialInfo) {
104
+ return { exists: true, isDir, info: initialInfo, inGracePeriod: false };
105
+ }
106
+ // 元数据缺失或不可解析,检查修改时间是否在宽限期内
107
+ const mtime = getLockMtimeMs(lockPath, isDir);
108
+ const age = Date.now() - mtime;
109
+ if (age < gracePeriodMs) {
110
+ // 处于宽限期内,说明并发所有者可能正在写入,进行有限次重试等待(严格受限于 deadline)
111
+ const maxRetries = 20;
112
+ for (let i = 0; i < maxRetries; i++) {
113
+ if (deadline !== undefined && Date.now() >= deadline) {
114
+ break;
115
+ }
116
+ const remaining = deadline !== undefined ? deadline - Date.now() : 50;
117
+ if (remaining <= 0) {
118
+ break;
119
+ }
120
+ sleepSync(Math.min(50, remaining));
121
+ const retriedInfo = tryParse();
122
+ if (retriedInfo) {
123
+ return { exists: true, isDir, info: retriedInfo, inGracePeriod: false };
124
+ }
125
+ if (!existsSync(lockPath)) {
126
+ return { exists: false, isDir: false, inGracePeriod: false };
127
+ }
128
+ }
129
+ const currentAge = Date.now() - getLockMtimeMs(lockPath, isDir);
130
+ if (currentAge < gracePeriodMs) {
131
+ return { exists: true, isDir, inGracePeriod: true };
132
+ }
133
+ }
134
+ return { exists: true, isDir, inGracePeriod: false };
135
+ }
136
+ /**
137
+ * 检查接管守卫(reclaim guard)目录状态。
138
+ *
139
+ * - 若目录不存在:返回非活跃。
140
+ * - 若处于宽限期内或持有者存活:返回活跃,禁止抢先创建主锁。
141
+ * - 若持有者已死亡且超过宽限期:返回陈旧,可供安全清理。
142
+ */
143
+ export function checkReclaimGuard(reclaimPath, gracePeriodMs = 1000) {
144
+ if (!existsSync(reclaimPath)) {
145
+ return { exists: false, active: false, isStale: false };
146
+ }
147
+ let isDir = false;
148
+ try {
149
+ isDir = statSync(reclaimPath).isDirectory();
150
+ }
151
+ catch {
152
+ return { exists: false, active: false, isStale: false };
153
+ }
154
+ const metaPath = isDir ? join(reclaimPath, METADATA_FILE) : reclaimPath;
155
+ const tryParse = () => {
156
+ try {
157
+ if (existsSync(metaPath)) {
158
+ const raw = readFileSync(metaPath, "utf8");
159
+ if (raw.trim().length > 0) {
160
+ const parsed = JSON.parse(raw);
161
+ if (parsed && typeof parsed.pid === "number") {
162
+ return parsed;
163
+ }
164
+ }
165
+ }
166
+ }
167
+ catch { }
168
+ return undefined;
169
+ };
170
+ const info = tryParse();
171
+ if (info && typeof info.pid === "number") {
172
+ const guardToken = typeof info.guardToken === "string" ? info.guardToken : undefined;
173
+ const createdAt = typeof info.createdAt === "number" ? info.createdAt : getLockMtimeMs(reclaimPath, isDir);
174
+ const age = Date.now() - createdAt;
175
+ if (isProcessAlive(info.pid)) {
176
+ return { exists: true, active: true, isStale: false, holderPid: info.pid, guardToken };
177
+ }
178
+ if (age < gracePeriodMs) {
179
+ return { exists: true, active: true, isStale: false, holderPid: info.pid, guardToken };
180
+ }
181
+ return { exists: true, active: false, isStale: true, holderPid: info.pid, guardToken };
182
+ }
183
+ const mtime = getLockMtimeMs(reclaimPath, isDir);
184
+ const age = Date.now() - mtime;
185
+ if (age < gracePeriodMs) {
186
+ return { exists: true, active: true, isStale: false };
187
+ }
188
+ return { exists: true, active: false, isStale: true };
189
+ }
190
+ /**
191
+ * 尝试原子获取接管守卫(reclaim guard)。
192
+ * 基于 mkdirSync 原子创建目录并写入自身元数据与唯一 guardToken。
193
+ */
194
+ export function tryAcquireReclaimGuard(reclaimPath, pid, guardToken) {
195
+ try {
196
+ mkdirSync(reclaimPath, { mode: 0o700 });
197
+ const metaPath = join(reclaimPath, METADATA_FILE);
198
+ const tmpPath = join(reclaimPath, `${METADATA_FILE}.tmp.${pid}.${randomUUID().slice(0, 8)}`);
199
+ writeFileSync(tmpPath, JSON.stringify({ pid, guardToken, createdAt: Date.now() }, null, 2), { mode: 0o600 });
200
+ renameSync(tmpPath, metaPath);
201
+ return true;
202
+ }
203
+ catch {
204
+ return false;
205
+ }
206
+ }
207
+ /**
208
+ * 安全清理陈旧接管守卫(reclaim guard)。
209
+ * 采用原子重命名检疫并严格核对 guardToken 与持有者存活状态,确保仅删除目标陈旧 guard,严禁误删活跃守卫或并发新守卫。
210
+ *
211
+ * @param reclaimPath 目标守卫路径
212
+ * @param expectedGuardToken 预期持有的守卫令牌(必填,拒绝未指定令牌的盲目清理)
213
+ */
214
+ export function safeRemoveStaleReclaimGuard(reclaimPath, expectedGuardToken) {
215
+ if (!expectedGuardToken || typeof expectedGuardToken !== "string")
216
+ return;
217
+ if (!existsSync(reclaimPath))
218
+ return;
219
+ let isDir = false;
220
+ try {
221
+ isDir = statSync(reclaimPath).isDirectory();
222
+ }
223
+ catch {
224
+ return;
225
+ }
226
+ const metaPath = isDir ? join(reclaimPath, METADATA_FILE) : reclaimPath;
227
+ if (!existsSync(metaPath)) {
228
+ return;
229
+ }
230
+ try {
231
+ const metaStat = statSync(metaPath);
232
+ const mtimeMs = metaStat.mtimeMs;
233
+ const raw = readFileSync(metaPath, "utf8");
234
+ if (raw.trim().length === 0) {
235
+ return;
236
+ }
237
+ const parsed = JSON.parse(raw);
238
+ if (parsed?.guardToken !== expectedGuardToken) {
239
+ return;
240
+ }
241
+ // 自身验证 fail-closed:
242
+ // 若不是当前进程自身清理自身守卫(即 parsed.pid !== process.pid):
243
+ // 验证持有者 PID 是否存活,若仍存活则判定守卫仍处于活跃状态,严禁删除;
244
+ // 且验证是否处于创建宽限期内(1000ms),若在宽限期内严禁删除。
245
+ if (parsed?.pid !== process.pid) {
246
+ if (typeof parsed?.pid === "number" && isProcessAlive(parsed.pid)) {
247
+ return;
248
+ }
249
+ if (Date.now() - mtimeMs < 1000) {
250
+ return;
251
+ }
252
+ }
253
+ }
254
+ catch {
255
+ return;
256
+ }
257
+ const quarantinePath = `${reclaimPath}.quarantine.${process.pid}.${Date.now()}.${randomUUID().slice(0, 8)}`;
258
+ try {
259
+ fs.renameSync(reclaimPath, quarantinePath);
260
+ }
261
+ catch {
262
+ return;
263
+ }
264
+ try {
265
+ const quarantinedMetaPath = isDir ? join(quarantinePath, METADATA_FILE) : quarantinePath;
266
+ let actualGuardToken;
267
+ let actualPid;
268
+ if (existsSync(quarantinedMetaPath)) {
269
+ try {
270
+ const raw = readFileSync(quarantinedMetaPath, "utf8");
271
+ if (raw.trim().length > 0) {
272
+ const parsed = JSON.parse(raw);
273
+ actualGuardToken = parsed?.guardToken;
274
+ actualPid = typeof parsed?.pid === "number" ? parsed.pid : undefined;
275
+ }
276
+ }
277
+ catch { }
278
+ }
279
+ // 后置校验:必须再次核对 actualGuardToken 与 expectedGuardToken 一致,
280
+ // 且若持有者非自身,必须确保 actualPid 确已死亡。
281
+ if (expectedGuardToken !== actualGuardToken ||
282
+ (actualPid !== undefined && actualPid !== process.pid && isProcessAlive(actualPid))) {
283
+ // 并非此前检查的目标陈旧 guard(已被并发者替换或持有者存活)
284
+ // 保持隔离状态,转换为 .orphan 脱离态,脱离创建者 Host PID 的存活保护,由 GC 基于规范路径凭据与时间清理
285
+ const orphanPath = `${reclaimPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`;
286
+ try {
287
+ fs.renameSync(quarantinePath, orphanPath);
288
+ }
289
+ catch { }
290
+ return;
291
+ }
292
+ // 确认正是目标陈旧 guard(或自身持有的 guard),安全清理
293
+ fs.rmSync(quarantinePath, { recursive: true, force: true });
294
+ }
295
+ catch {
296
+ // 发生异常,保持隔离状态,转换为 .orphan 脱离态
297
+ const orphanPath = `${reclaimPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`;
298
+ try {
299
+ fs.renameSync(quarantinePath, orphanPath);
300
+ }
301
+ catch { }
302
+ }
303
+ }
304
+ /**
305
+ * 从隔离或回滚临时目录名称中解析操作者 PID 与隔离生成时间戳。
306
+ * 命名规范:*.(quarantine|rollback|release).<pid>.<timestamp>.<uuid> 或 *.orphan.<timestamp>.<uuid>
307
+ */
308
+ export function parseQuarantineTimestamp(entryName) {
309
+ const orphanMatch = entryName.match(/\.orphan\.(\d+)(?:\.|$)/);
310
+ if (orphanMatch && orphanMatch[1]) {
311
+ const ts = parseInt(orphanMatch[1], 10);
312
+ return {
313
+ timestamp: !Number.isNaN(ts) && ts > 0 ? ts : undefined,
314
+ };
315
+ }
316
+ const match = entryName.match(/\.(?:quarantine|rollback|release)\.(\d+)\.(\d+)(?:\.|$)/);
317
+ if (match && match[1] && match[2]) {
318
+ const pid = parseInt(match[1], 10);
319
+ const ts = parseInt(match[2], 10);
320
+ return {
321
+ operatorPid: !Number.isNaN(pid) && pid > 0 ? pid : undefined,
322
+ timestamp: !Number.isNaN(ts) && ts > 0 ? ts : undefined,
323
+ };
324
+ }
325
+ const fallbackMatch = entryName.match(/\.(?:quarantine|rollback|release)\.(\d+)(?:\.|$)/);
326
+ if (fallbackMatch && fallbackMatch[1]) {
327
+ const ts = parseInt(fallbackMatch[1], 10);
328
+ return {
329
+ timestamp: !Number.isNaN(ts) && ts > 0 ? ts : undefined,
330
+ };
331
+ }
332
+ return {};
333
+ }
334
+ /**
335
+ * 读取目标规范路径(主锁目录或 reclaim 目录)中记录的当前有效令牌凭据。
336
+ */
337
+ function readCanonicalToken(targetPath) {
338
+ try {
339
+ if (!existsSync(targetPath)) {
340
+ return undefined;
341
+ }
342
+ let isDir = false;
343
+ try {
344
+ isDir = statSync(targetPath).isDirectory();
345
+ }
346
+ catch {
347
+ return undefined;
348
+ }
349
+ const metaPath = isDir ? join(targetPath, METADATA_FILE) : targetPath;
350
+ if (!existsSync(metaPath)) {
351
+ return undefined;
352
+ }
353
+ const raw = readFileSync(metaPath, "utf8");
354
+ if (raw.trim().length === 0) {
355
+ return undefined;
356
+ }
357
+ const parsed = JSON.parse(raw);
358
+ if (typeof parsed?.guardToken === "string" && parsed.guardToken.length > 0) {
359
+ return parsed.guardToken;
360
+ }
361
+ if (typeof parsed?.lockToken === "string" && parsed.lockToken.length > 0) {
362
+ return parsed.lockToken;
363
+ }
364
+ return undefined;
365
+ }
366
+ catch {
367
+ return undefined;
368
+ }
369
+ }
370
+ /**
371
+ * 清理过期的隔离目录(GC 回收机制)。
372
+ * 实施分流存活校验,防止误删活跃持锁者与回滚中目录,同时防止孤儿目录无限泄漏:
373
+ * - 共同前置条件:存活年龄必须超过 maxAgeMs(默认 10000ms),未超期前严禁清理(保持宽限期)。
374
+ * - 活跃隔离目录(quarantine / rollback / release):若 operatorPid 存活或内部 metadata.json 记录的 pid 与 childPids 存活,跳过不予清理。
375
+ * - 孤儿脱离目录(orphan):内部旧 metadata.pid 不再作为续命依据;提取其内部 orphanToken,仅当当前规范主路径(主锁目录与 reclaim 目录)依然持有该凭证时保守跳过;规范路径不存在或已变更凭据时直接安全清理。
376
+ */
377
+ export function cleanStaleQuarantines(parentDir, basePrefix, maxAgeMs = 10000, deadline) {
378
+ try {
379
+ if (!existsSync(parentDir))
380
+ return;
381
+ const entries = readdirSync(parentDir);
382
+ const now = Date.now();
383
+ const canonicalPrimaryLockPath = basePrefix.endsWith(RECLAIM_SUFFIX)
384
+ ? join(parentDir, basePrefix.slice(0, -RECLAIM_SUFFIX.length))
385
+ : join(parentDir, basePrefix);
386
+ const canonicalReclaimPath = `${canonicalPrimaryLockPath}${RECLAIM_SUFFIX}`;
387
+ for (const entry of entries) {
388
+ if (deadline !== undefined && Date.now() >= deadline) {
389
+ break;
390
+ }
391
+ if (!entry.startsWith(basePrefix))
392
+ continue;
393
+ if (!entry.includes(".quarantine.") &&
394
+ !entry.includes(".rollback.") &&
395
+ !entry.includes(".release.") &&
396
+ !entry.includes(".orphan.")) {
397
+ continue;
398
+ }
399
+ const fullPath = join(parentDir, entry);
400
+ try {
401
+ const { operatorPid, timestamp } = parseQuarantineTimestamp(entry);
402
+ // 共同前置条件:存活年龄必须超过 maxAgeMs,未超期前严禁清理(保持宽限期)
403
+ let age;
404
+ if (timestamp !== undefined) {
405
+ age = now - timestamp;
406
+ }
407
+ else {
408
+ const stat = statSync(fullPath);
409
+ age = now - stat.mtimeMs;
410
+ }
411
+ if (age <= maxAgeMs) {
412
+ continue;
413
+ }
414
+ const isOrphan = entry.includes(".orphan.");
415
+ if (!isOrphan) {
416
+ // 活跃的 quarantine / rollback / release 目录:
417
+ // 若 operatorPid 存活,跳过不予清理
418
+ if (operatorPid !== undefined && isProcessAlive(operatorPid)) {
419
+ continue;
420
+ }
421
+ // 若内部 metadata.json 记录的 pid 或 childPids 存活,跳过不予清理
422
+ let isDir = false;
423
+ try {
424
+ isDir = statSync(fullPath).isDirectory();
425
+ }
426
+ catch {
427
+ continue;
428
+ }
429
+ const metaPath = isDir ? join(fullPath, METADATA_FILE) : fullPath;
430
+ if (existsSync(metaPath)) {
431
+ try {
432
+ const raw = readFileSync(metaPath, "utf8");
433
+ if (raw.trim().length > 0) {
434
+ const meta = JSON.parse(raw);
435
+ if (typeof meta?.pid === "number" && isProcessAlive(meta.pid)) {
436
+ continue;
437
+ }
438
+ if (Array.isArray(meta?.childPids) &&
439
+ meta.childPids.some((childPid) => typeof childPid === "number" && isProcessAlive(childPid))) {
440
+ continue;
441
+ }
442
+ }
443
+ }
444
+ catch {
445
+ // 元数据损坏或不可读,不视为存在存活持有者
446
+ }
447
+ }
448
+ rmSync(fullPath, { recursive: true, force: true });
449
+ continue;
450
+ }
451
+ // 孤儿脱离目录(已脱离规范路径):
452
+ // 严禁再用内部旧 metadata.pid 存活为由无限续命,
453
+ // 提取 orphan 内部记录的 orphanToken(guardToken ?? lockToken)
454
+ const orphanToken = readCanonicalToken(fullPath);
455
+ // 核对当前 canonical 规范主路径(主锁目录与 reclaim 目录)的当前凭证:
456
+ // 仅当当前规范路径依然持有该 orphanToken 时保守跳过;若规范路径不存在或已切换为其他 token,直接执行 rmSync 安全清理
457
+ const primaryToken = readCanonicalToken(canonicalPrimaryLockPath);
458
+ const reclaimToken = readCanonicalToken(canonicalReclaimPath);
459
+ if (orphanToken !== undefined &&
460
+ (orphanToken === primaryToken || orphanToken === reclaimToken)) {
461
+ continue;
462
+ }
463
+ rmSync(fullPath, { recursive: true, force: true });
464
+ }
465
+ catch { }
466
+ }
467
+ }
468
+ catch { }
469
+ }
470
+ /**
471
+ * 安全回滚当前进程创建的主锁。
472
+ * 优先核对 lockToken,回退核对 sessionToken,防止误删接管者或并发新锁。
473
+ */
474
+ export function safeRollbackLock(lockPath, expectedSessionToken, expectedLockToken) {
475
+ if (!existsSync(lockPath))
476
+ return;
477
+ const quarantinePath = `${lockPath}.rollback.${process.pid}.${Date.now()}.${randomUUID().slice(0, 8)}`;
478
+ try {
479
+ renameSync(lockPath, quarantinePath);
480
+ }
481
+ catch {
482
+ return;
483
+ }
484
+ try {
485
+ let isDir = false;
486
+ try {
487
+ isDir = statSync(quarantinePath).isDirectory();
488
+ }
489
+ catch {
490
+ isDir = false;
491
+ }
492
+ const metaPath = isDir ? join(quarantinePath, METADATA_FILE) : quarantinePath;
493
+ let actualSessionToken;
494
+ let actualLockToken;
495
+ if (existsSync(metaPath)) {
496
+ try {
497
+ const raw = readFileSync(metaPath, "utf8");
498
+ const parsed = JSON.parse(raw);
499
+ actualSessionToken = parsed?.sessionToken;
500
+ actualLockToken = parsed?.lockToken;
501
+ }
502
+ catch { }
503
+ }
504
+ const isMatch = expectedLockToken
505
+ ? (actualLockToken === expectedLockToken && actualSessionToken === expectedSessionToken)
506
+ : (actualSessionToken === expectedSessionToken);
507
+ if (!isMatch) {
508
+ // 并非自身刚才创建的锁目录,立即恢复原位!
509
+ try {
510
+ renameSync(quarantinePath, lockPath);
511
+ }
512
+ catch {
513
+ try {
514
+ renameSync(quarantinePath, `${lockPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`);
515
+ }
516
+ catch { }
517
+ }
518
+ return;
519
+ }
520
+ rmSync(quarantinePath, { recursive: true, force: true });
521
+ }
522
+ catch {
523
+ try {
524
+ renameSync(quarantinePath, lockPath);
525
+ }
526
+ catch {
527
+ try {
528
+ renameSync(quarantinePath, `${lockPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`);
529
+ }
530
+ catch { }
531
+ }
532
+ }
533
+ }
534
+ /**
535
+ * 安全隔离并清理陈旧主锁。
536
+ * 隔离后复核元数据,确保仅删除目标陈旧锁,若已被更新或仍有存活所有者则恢复原位。
537
+ */
538
+ export function safeQuarantineStaleLock(lockPath, expectedSessionToken, expectedLockToken) {
539
+ if (!existsSync(lockPath))
540
+ return true;
541
+ const quarantinePath = `${lockPath}.quarantine.${process.pid}.${Date.now()}.${randomUUID().slice(0, 8)}`;
542
+ try {
543
+ renameSync(lockPath, quarantinePath);
544
+ }
545
+ catch {
546
+ return false;
547
+ }
548
+ try {
549
+ let isDir = false;
550
+ try {
551
+ isDir = statSync(quarantinePath).isDirectory();
552
+ }
553
+ catch {
554
+ isDir = false;
555
+ }
556
+ const metaPath = isDir ? join(quarantinePath, METADATA_FILE) : quarantinePath;
557
+ let actualSessionToken;
558
+ let actualLockToken;
559
+ let actualPid;
560
+ if (existsSync(metaPath)) {
561
+ try {
562
+ const raw = readFileSync(metaPath, "utf8");
563
+ const parsed = JSON.parse(raw);
564
+ actualSessionToken = parsed?.sessionToken;
565
+ actualLockToken = parsed?.lockToken;
566
+ actualPid = parsed?.pid;
567
+ }
568
+ catch { }
569
+ }
570
+ const tokenMismatch = expectedLockToken
571
+ ? (actualLockToken !== expectedLockToken || (expectedSessionToken && actualSessionToken !== expectedSessionToken))
572
+ : ((expectedSessionToken && actualSessionToken !== expectedSessionToken) || (!expectedSessionToken && actualSessionToken));
573
+ if (tokenMismatch) {
574
+ // 锁已被其他竞争者接管并写入新 token,绝不可删除!立即恢复原位
575
+ try {
576
+ renameSync(quarantinePath, lockPath);
577
+ }
578
+ catch {
579
+ try {
580
+ renameSync(quarantinePath, `${lockPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`);
581
+ }
582
+ catch { }
583
+ }
584
+ return false;
585
+ }
586
+ if (typeof actualPid === "number" && isProcessAlive(actualPid)) {
587
+ // 持有者实际仍存活,恢复原位
588
+ try {
589
+ renameSync(quarantinePath, lockPath);
590
+ }
591
+ catch {
592
+ try {
593
+ renameSync(quarantinePath, `${lockPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`);
594
+ }
595
+ catch { }
596
+ }
597
+ return false;
598
+ }
599
+ // 确认正是目标陈旧锁,安全删除
600
+ rmSync(quarantinePath, { recursive: true, force: true });
601
+ return true;
602
+ }
603
+ catch {
604
+ try {
605
+ renameSync(quarantinePath, lockPath);
606
+ }
607
+ catch {
608
+ try {
609
+ renameSync(quarantinePath, `${lockPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`);
610
+ }
611
+ catch { }
612
+ }
613
+ return false;
614
+ }
615
+ }
616
+ /**
617
+ * 安全释放主锁。
618
+ * 仅当锁目录中持有当前 lockToken / sessionToken 时才删除,杜绝删除他人新锁。
619
+ * 传入 gcAfterRelease 时在释放结束后以主锁文件名为前缀触发一轮隔离目录 GC。
620
+ */
621
+ export function safeReleaseLock(lockPath, expectedSessionToken, expectedLockToken, options) {
622
+ if (!existsSync(lockPath))
623
+ return;
624
+ const quarantinePath = `${lockPath}.release.${process.pid}.${Date.now()}.${randomUUID().slice(0, 8)}`;
625
+ try {
626
+ renameSync(lockPath, quarantinePath);
627
+ }
628
+ catch {
629
+ return;
630
+ }
631
+ try {
632
+ let isDir = false;
633
+ try {
634
+ isDir = statSync(quarantinePath).isDirectory();
635
+ }
636
+ catch {
637
+ isDir = false;
638
+ }
639
+ const metaPath = isDir ? join(quarantinePath, METADATA_FILE) : quarantinePath;
640
+ let actualSessionToken;
641
+ let actualLockToken;
642
+ if (existsSync(metaPath)) {
643
+ try {
644
+ const raw = readFileSync(metaPath, "utf8");
645
+ const parsed = JSON.parse(raw);
646
+ actualSessionToken = parsed?.sessionToken;
647
+ actualLockToken = parsed?.lockToken;
648
+ }
649
+ catch { }
650
+ }
651
+ const isMatch = expectedLockToken
652
+ ? (actualLockToken === expectedLockToken && actualSessionToken === expectedSessionToken)
653
+ : (actualSessionToken === expectedSessionToken);
654
+ if (isMatch) {
655
+ rmSync(quarantinePath, { recursive: true, force: true });
656
+ }
657
+ else {
658
+ // 并非当前会话持有的锁,恢复原位
659
+ try {
660
+ renameSync(quarantinePath, lockPath);
661
+ }
662
+ catch {
663
+ try {
664
+ renameSync(quarantinePath, `${lockPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`);
665
+ }
666
+ catch { }
667
+ }
668
+ }
669
+ }
670
+ catch {
671
+ try {
672
+ renameSync(quarantinePath, lockPath);
673
+ }
674
+ catch {
675
+ try {
676
+ renameSync(quarantinePath, `${lockPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`);
677
+ }
678
+ catch { }
679
+ }
680
+ }
681
+ if (options?.gcAfterRelease) {
682
+ cleanStaleQuarantines(dirname(lockPath), basename(lockPath));
683
+ }
684
+ }
685
+ /**
686
+ * 向主锁目录原子写入元数据。
687
+ * 先写入临时文件再通过 renameSync 原子替换,消除 truncate 空文件窗口。
688
+ */
689
+ function writePrimaryLockMetadata(lockPath, content) {
690
+ const metaPath = join(lockPath, METADATA_FILE);
691
+ const tmpPath = join(lockPath, `${METADATA_FILE}.tmp.${process.pid}.${randomUUID().slice(0, 8)}`);
692
+ writeFileSync(tmpPath, content, { mode: 0o600 });
693
+ renameSync(tmpPath, metaPath);
694
+ }
695
+ /**
696
+ * 目录锁统一获取仲裁流程。
697
+ *
698
+ * 仲裁规则:
699
+ * - 引入所有竞争者均遵守的原子 reclaim guard 机制(lockPath.reclaim),每个 guard 具备全局唯一 guardToken。
700
+ * - 基于原子目录创建 mkdirSync(lockPath, { mode: 0o700 }) 确立所有权,并在其下存放 metadata.json。
701
+ * - 若检测到 reclaim guard 存在且处于宽限期内或持有者存活,必须等待,禁止抢先创建主锁。
702
+ * - 若锁目录已存在,通过宽限期机制防止将并发写入中的元数据误判为锁死亡。
703
+ * - 若持有者仍存活(判定由 assertStaleHolderReclaimable 注入),抛出上层领域错误拒绝并发接管。
704
+ * - 当识别到主锁为陈旧锁时,竞争者必须先原子竞争获取 reclaim guard。
705
+ * - 仅成功获取 reclaim guard 的唯一胜利者获准执行:复核主锁陈旧性 -> 验证并清理陈旧主锁 -> 原子创建新主锁并写入自身元数据 -> 清理 reclaim guard。
706
+ * - 竞争失败者等待并 continue 重试;若 reclaim guard 持有者意外崩溃,其他竞争者在超过宽限期且 PID 已死后通过 safeRemoveStaleReclaimGuard 核对 guardToken 并清理。
707
+ * - 严禁在获取新主锁遇到 EEXIST 时盲目 rmSync;后检发现他人活跃 guard 时,仅通过 safeRollbackLock 核对自身 sessionToken 回滚自身锁。
708
+ */
709
+ export function acquireDirectoryLock(config) {
710
+ const { lockPath, parentDir, basePrefix, metadataContent, sessionToken, lockToken, createLockError, messages, assertStaleHolderReclaimable, onAcquired, } = config;
711
+ const timeoutMs = config.acquireTimeoutMs ?? 5000;
712
+ const deadline = Date.now() + timeoutMs;
713
+ const reclaimPath = `${lockPath}${RECLAIM_SUFFIX}`;
714
+ const currentPid = process.pid;
715
+ const content = metadataContent;
716
+ const throwLockError = (message) => {
717
+ throw createLockError(message);
718
+ };
719
+ cleanStaleQuarantines(parentDir, basePrefix, 10000, deadline);
720
+ while (true) {
721
+ // 当竞争者尝试创建新主锁时,若检测到 reclaim 目录存在且处于宽限期内或持有者存活,必须等待,禁止在他人正在接管/验证期间抢先创建主锁
722
+ const reclaimState = checkReclaimGuard(reclaimPath, 1000);
723
+ if (reclaimState.active) {
724
+ if (Date.now() >= deadline) {
725
+ throwLockError(messages.timeoutWaitingReclaimGuard(reclaimState.holderPid));
726
+ }
727
+ sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
728
+ continue;
729
+ }
730
+ if (reclaimState.isStale && reclaimState.guardToken) {
731
+ // 若 reclaim guard 持有者意外崩溃,其他竞争者核对此前检查的 guardToken 安全清理
732
+ safeRemoveStaleReclaimGuard(reclaimPath, reclaimState.guardToken);
733
+ }
734
+ // 尝试常规获取新主锁
735
+ try {
736
+ fs.mkdirSync(lockPath, { mode: 0o700 });
737
+ writePrimaryLockMetadata(lockPath, content);
738
+ // 再次确认在此窗口期内是否有他人持有活跃 reclaim guard
739
+ const postCheck = checkReclaimGuard(reclaimPath, 1000);
740
+ if (postCheck.active && postCheck.holderPid !== currentPid) {
741
+ // 仅当锁目录中包含自身创建的 sessionToken 与 lockToken 时安全回滚,杜绝误删他人新锁
742
+ safeRollbackLock(lockPath, sessionToken, lockToken);
743
+ if (Date.now() >= deadline) {
744
+ throwLockError(messages.timeoutAcquireBlockedByGuard(postCheck.holderPid));
745
+ }
746
+ sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
747
+ continue;
748
+ }
749
+ return onAcquired();
750
+ }
751
+ catch (err) {
752
+ if (err && (err.code === "EEXIST" || err.code === "ENOENT")) {
753
+ const lockState = readLockWithGracePeriod(lockPath, 3000, deadline);
754
+ if (!lockState.exists) {
755
+ if (Date.now() >= deadline) {
756
+ throwLockError(messages.timeoutAcquire());
757
+ }
758
+ continue;
759
+ }
760
+ if (lockState.inGracePeriod) {
761
+ if (Date.now() >= deadline) {
762
+ throwLockError(messages.timeoutGracePeriod());
763
+ }
764
+ sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
765
+ continue;
766
+ }
767
+ if (lockState.info) {
768
+ assertStaleHolderReclaimable(lockState.info);
769
+ }
770
+ const staleSessionToken = lockState.info?.sessionToken;
771
+ const staleLockToken = lockState.info?.lockToken;
772
+ const guardToken = randomUUID();
773
+ // 当识别到主锁为陈旧锁时,竞争者必须先原子竞争获取 reclaim guard
774
+ const acquiredReclaim = tryAcquireReclaimGuard(reclaimPath, currentPid, guardToken);
775
+ if (!acquiredReclaim) {
776
+ const currentReclaim = checkReclaimGuard(reclaimPath, 1000);
777
+ if (currentReclaim.isStale && currentReclaim.guardToken) {
778
+ safeRemoveStaleReclaimGuard(reclaimPath, currentReclaim.guardToken);
779
+ }
780
+ if (Date.now() >= deadline) {
781
+ throwLockError(messages.timeoutGuardContention());
782
+ }
783
+ sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
784
+ continue;
785
+ }
786
+ // 仅成功获取 reclaim guard 的唯一胜利者获准执行:
787
+ // 复核主锁陈旧性 -> 隔离/清理陈旧锁 -> 原子创建新主锁并写入自身元数据 -> 清理 reclaim guard
788
+ try {
789
+ const verifyReclaimOwnership = () => {
790
+ const ownGuard = checkReclaimGuard(reclaimPath, 1000);
791
+ return Boolean(ownGuard.active &&
792
+ ownGuard.holderPid === currentPid &&
793
+ ownGuard.guardToken === guardToken);
794
+ };
795
+ // 自检校验自身守卫:验证自身 guardToken 依然有效且持有者为自身 PID
796
+ // 若已被抢占或不匹配,立即退出当前接管并 continue 重试,杜绝在守卫已失窃的情况下操作主锁
797
+ if (!verifyReclaimOwnership()) {
798
+ if (Date.now() >= deadline) {
799
+ throwLockError(messages.timeoutAcquire());
800
+ }
801
+ sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
802
+ continue;
803
+ }
804
+ // 复核主锁陈旧性
805
+ const recheckState = readLockWithGracePeriod(lockPath, 1000, deadline);
806
+ if (recheckState.exists) {
807
+ if (recheckState.inGracePeriod) {
808
+ if (Date.now() >= deadline) {
809
+ throwLockError(messages.timeoutGracePeriod());
810
+ }
811
+ sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
812
+ continue;
813
+ }
814
+ if (recheckState.info) {
815
+ assertStaleHolderReclaimable(recheckState.info);
816
+ }
817
+ // 隔离并核验清理陈旧主锁(验证 sessionToken 一致,若已被他人占用则恢复原位)
818
+ const cleaned = safeQuarantineStaleLock(lockPath, recheckState.info?.sessionToken ?? staleSessionToken, recheckState.info?.lockToken ?? staleLockToken);
819
+ if (!cleaned) {
820
+ if (Date.now() >= deadline) {
821
+ throwLockError(messages.timeoutReclaimStale());
822
+ }
823
+ continue;
824
+ }
825
+ }
826
+ // 后置复核:在清理陈旧主锁之后、创建新主锁之前,再次核验 reclaim guard 所有权
827
+ // 若守卫在此期间失窃,严禁继续创建新主锁,立即退出重试
828
+ if (!verifyReclaimOwnership()) {
829
+ if (Date.now() >= deadline) {
830
+ throwLockError(messages.timeoutAcquire());
831
+ }
832
+ sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
833
+ continue;
834
+ }
835
+ // 原子创建新主锁并写入自身元数据(严格遵守 deadline,并在每次重试检查时间窗口)
836
+ let created = false;
837
+ for (let attempt = 0; attempt < 40; attempt++) {
838
+ try {
839
+ fs.mkdirSync(lockPath, { mode: 0o700 });
840
+ created = true;
841
+ break;
842
+ }
843
+ catch (createErr) {
844
+ if (createErr?.code === "EEXIST") {
845
+ if (Date.now() >= deadline) {
846
+ break;
847
+ }
848
+ const remaining = deadline - Date.now();
849
+ if (remaining <= 0) {
850
+ break;
851
+ }
852
+ sleepSync(Math.min(25, remaining));
853
+ continue;
854
+ }
855
+ throw createErr;
856
+ }
857
+ }
858
+ if (!created) {
859
+ if (Date.now() >= deadline) {
860
+ throwLockError(messages.timeoutCreate());
861
+ }
862
+ continue;
863
+ }
864
+ writePrimaryLockMetadata(lockPath, content);
865
+ // 后置复核:新主锁创建完成后、返回之前再次核验 reclaim guard 所有权
866
+ // 若守卫在创建新主锁期间失窃,说明存在并发仲裁漂移,严禁生效并安全回滚自身主锁
867
+ if (!verifyReclaimOwnership()) {
868
+ safeRollbackLock(lockPath, sessionToken, lockToken);
869
+ if (Date.now() >= deadline) {
870
+ throwLockError(messages.timeoutAcquire());
871
+ }
872
+ sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
873
+ continue;
874
+ }
875
+ return onAcquired();
876
+ }
877
+ finally {
878
+ // 清理自身持有的 reclaim guard(必须核对自身 guardToken)
879
+ safeRemoveStaleReclaimGuard(reclaimPath, guardToken);
880
+ }
881
+ }
882
+ throw err;
883
+ }
884
+ }
885
+ }