@lark-codem/codem-cli 0.1.238-fornax-codem-ca.100102809488.0.g450a419 → 0.1.239
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 +11 -3
- package/profile/config-lock.js +512 -0
- package/profile/credential-errors.js +27 -0
- package/profile/credential-metadata.js +29 -0
- package/profile/debug.js +104 -0
- package/profile/device-identity.js +95 -0
- package/profile/diagnostic-id.js +47 -0
- package/profile/index.d.ts +61 -0
- package/profile/index.js +17 -0
- package/profile/profile-io.js +70 -0
- package/profile/profile-migration.js +1022 -0
- package/profile/profile-snapshot.js +120 -0
- package/profile/profile-writers.js +296 -0
- package/profile/runtime-logs.js +374 -0
- package/profile/slardar-pc-transport.js +38 -0
package/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lark-codem/codem-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.239",
|
|
4
4
|
"codemServiceManagementProtocol": 3,
|
|
5
|
+
"codemProfileProtocol": 1,
|
|
5
6
|
"type": "module",
|
|
6
7
|
"description": "CodeM local CLI for Feishu-driven coding sessions",
|
|
7
8
|
"license": "SEE LICENSE IN LICENSE",
|
|
8
9
|
"files": [
|
|
9
10
|
"bin",
|
|
11
|
+
"profile",
|
|
10
12
|
"LICENSE"
|
|
11
13
|
],
|
|
12
14
|
"os": [
|
|
@@ -27,10 +29,16 @@
|
|
|
27
29
|
"access": "public"
|
|
28
30
|
},
|
|
29
31
|
"dependencies": {
|
|
30
|
-
"@lark-codem/codem-core": "0.8.
|
|
32
|
+
"@lark-codem/codem-core": "0.8.71",
|
|
33
|
+
"zod": "3.25.76"
|
|
31
34
|
},
|
|
32
35
|
"optionalDependencies": {
|
|
33
|
-
"@lark-codem/codem-cli-
|
|
36
|
+
"@lark-codem/codem-cli-darwin-arm64": "0.1.239",
|
|
37
|
+
"@lark-codem/codem-cli-darwin-x64": "0.1.239",
|
|
38
|
+
"@lark-codem/codem-cli-linux-arm64": "0.1.239",
|
|
39
|
+
"@lark-codem/codem-cli-linux-x64": "0.1.239",
|
|
40
|
+
"@lark-codem/codem-cli-win32-arm64": "0.1.239",
|
|
41
|
+
"@lark-codem/codem-cli-win32-x64": "0.1.239"
|
|
34
42
|
},
|
|
35
43
|
"engines": {
|
|
36
44
|
"node": ">=18"
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, openSync, rmSync, writeSync } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import { debugLog } from "./debug.js";
|
|
8
|
+
|
|
9
|
+
// 跨进程 config 互斥锁(zero-dep:原子 create-with-content + owner PID liveness)。
|
|
10
|
+
//
|
|
11
|
+
// 背景:本机 daemon 轮询、daemon 启动、交互式 CLI、linco-runner、host-channel 各持
|
|
12
|
+
// 一个 OAuthTokenManager,共享 `~/.codem/config.json` 里同一个飞书 refresh token(RT)。
|
|
13
|
+
// 飞书 RT 单次使用、每次刷新即轮换:两个进程若并发刷新,一个赢家轮到新 RT 并落盘,
|
|
14
|
+
// 另一个拿着旧内存快照整文件写回就会把新 RT 覆盖成死 RT,下次刷新必 invalid_grant →
|
|
15
|
+
// OAuth 会话被清、被迫 `codem login --force`。ConfigStore 的 `#withWriteLock` 只是进程内
|
|
16
|
+
// promise 链,跨进程仍是 last-writer-wins。此锁把所有 refresh / auth 写盘收敛到一个
|
|
17
|
+
// 跨进程临界区,消除该竞态。
|
|
18
|
+
//
|
|
19
|
+
// 核心不变量:**存活进程持有的锁绝不会被回收**。破坏互斥的代价(死 RT → 强制重登)
|
|
20
|
+
// 远大于「等待者超时报一个瞬时错误」,所以任何「看起来像孤儿」都不足以触发回收——
|
|
21
|
+
// 只有**可证明无主**才回收。
|
|
22
|
+
//
|
|
23
|
+
// ── 为什么用 tmp + link 而不是 writeFile(flag:"wx") ──────────────────────────
|
|
24
|
+
//
|
|
25
|
+
// 锁文件必须「一出现就是完整的」。`writeFile(flag:"wx")` = open(O_CREAT|O_EXCL) +
|
|
26
|
+
// write + close 是**两步**:open 成功后锁文件已在盘上、大小为 0,body 要等下一个
|
|
27
|
+
// event-loop turn 的 write 才落盘。这中间的窗口不是纳秒级、而是**无界**的(线程池
|
|
28
|
+
// 饥饿 / 慢同步盘 iCloud·NFS / 进程被 SIGTSTP 停在两步之间)。于是「活着的持有者持有
|
|
29
|
+
// 一个 0 字节锁」是可达状态——曾据此写过一版「回收 0 字节锁」的自愈,被对抗式 PoC 测出
|
|
30
|
+
// maxActive=2(回收者删掉了活持有者正在写的锁),是一次真实的互斥破坏。
|
|
31
|
+
//
|
|
32
|
+
// 正解是从**源头**消灭 0 字节窗口,而不是事后回收它:
|
|
33
|
+
// 1. 把完整 body 写进临时文件 `<lock>.tmp-<token>`(tmp 不在锁路径上,谁也不看它)。
|
|
34
|
+
// 2. `fs.link(tmp, lockFile)` 原子上位——link 要么让 lockFile 指向那个**已写满**的
|
|
35
|
+
// inode,要么 EEXIST(已被持有)。lockFile 因此**不可能**以 0 字节 / 半截 body 的
|
|
36
|
+
// 形态存在,哪怕一瞬。write 失败也只弄脏 tmp,锁路径始终干净,绝不留死锁残骸。
|
|
37
|
+
// 这样锁文件恒为完整,回收判定不必再去猜「0 字节是死是活」——那条分支整条不存在。
|
|
38
|
+
//
|
|
39
|
+
// ── 不支持 hardlink 的文件系统(降级路径) ──────────────────────────────────
|
|
40
|
+
//
|
|
41
|
+
// 有些文件系统压根不实现 link(2):veFaaS 容器的 /home、部分 FUSE / 网络盘,link 直接返回
|
|
42
|
+
// ENOTSUP / EPERM / ENOSYS。这类错误不是「锁被占」而是「这台机器上锁机制不可用」,硬抛出去
|
|
43
|
+
// 会让**每一次** config 写盘(login / token 刷新 / 切空间)无条件失败——线上就是这么炸的。
|
|
44
|
+
//
|
|
45
|
+
// 降级到 O_CREAT|O_EXCL 独占创建(`open(lockFile, "wx")`):
|
|
46
|
+
// - 互斥性不变:O_EXCL 本身即独占,两个进程不可能同时创建成功。
|
|
47
|
+
// - 代价是重新出现 create→write 之间的 0 字节窗口。用**同步** open/write/close 把它压进
|
|
48
|
+
// 一条同步代码路径:没有 event-loop turn、不受线程池饥饿或慢盘 await 影响,只有
|
|
49
|
+
// SIGKILL / 断电能命中(上面那版被 PoC 打穿的旧实现是异步两步,窗口无界)。
|
|
50
|
+
// - 即便命中,回收器对读不出 owner 的锁一律判 owner_unreadable → 不回收,所以最坏结果是
|
|
51
|
+
// 留一具需人工 rm 的 0 字节残骸(超时错误里已带提示),**绝不会误删活持有者的锁**——
|
|
52
|
+
// 「存活进程持有的锁绝不被回收」这条不变量在降级路径上同样成立。
|
|
53
|
+
// update-service.js 的 acquireUpdateLock 早有同一降级(ENOTSUP/EPERM/ENOSYS → wx)。
|
|
54
|
+
//
|
|
55
|
+
// ── release 与回收 ────────────────────────────────────────────────────────
|
|
56
|
+
//
|
|
57
|
+
// - release:读锁文件 token,匹配自己的才 unlink;不匹配(锁已易主)→ no-op,绝不移动
|
|
58
|
+
// 或删除别人的锁。锁文件要么在锁路径上、要么不存在,等待者的原子 link 语义始终成立。
|
|
59
|
+
// - 回收(orphan reaping)只认**可证明无主**:owner `host` 为本机 **且** kill(pid,0) 抛
|
|
60
|
+
// ESRCH(PID 明确不存在)。EPERM / 未知错误 / 元数据不可读 / 异机 host 一律按存活处理
|
|
61
|
+
// ——PID 复用等边界最多让锁延迟释放,绝不以破坏互斥换可用性。回收者通过
|
|
62
|
+
// `<lock>.reap/` 下的唯一候选做 Bakery 选举,把 token 复核与 unlink 串成单回收者
|
|
63
|
+
// 临界区;候选绑定 PID,进程崩溃后可被后续回收者安全清理。
|
|
64
|
+
// - tmp 清扫:获取时顺带扫描同目录的 `<lock>.tmp-*` 兄弟,仅回收「可证明无主」
|
|
65
|
+
// (body 可读 + 本机 host + kill(pid,0) ESRCH)的残留——等待窗口内被 SIGKILL/Ctrl+C/
|
|
66
|
+
// 断电终止的进程留下的 tmp 由此收敛,body 读不出的一律不动(见 sweepAbandonedTmpFiles)。
|
|
67
|
+
//
|
|
68
|
+
// ⚠️ 明确不做「回收 0 字节锁」的自愈:新代码永不产生 0 字节锁,但 rolling upgrade 期间
|
|
69
|
+
// 旧版进程仍可能瞬时留下 0 字节锁,且无法与旧版崩溃残骸区分——回收它就会误删活持有者。
|
|
70
|
+
// 一个升级前遗留的 0 字节残骸会走 owner_unreadable 分支超时(诊断会报 size=0,运维照提示
|
|
71
|
+
// rm 即可)。这是可控的一次性过渡代价,换取的是绝不破坏互斥。
|
|
72
|
+
|
|
73
|
+
const DEFAULT_STALE_MS = 10_000;
|
|
74
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
75
|
+
const DEFAULT_RETRY_MS = 75;
|
|
76
|
+
const REAPER_CHOOSING_PREFIX = "choosing-";
|
|
77
|
+
const REAPER_TICKET_PREFIX = "ticket-";
|
|
78
|
+
const REAPER_TICKET_WIDTH = 16;
|
|
79
|
+
|
|
80
|
+
function defaultSleep(ms) {
|
|
81
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 重试间隔加抖动,避免多个等待者被唤醒后同频撞锁(thundering herd)。
|
|
85
|
+
function jitter(baseMs) {
|
|
86
|
+
return baseMs + Math.floor(Math.random() * baseMs);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// kill(pid, 0) 不发信号只探测:ESRCH = 确认不存在(唯一算「死」的判定);
|
|
90
|
+
// EPERM(存在但无权限)与任何未知错误一律按存活处理。
|
|
91
|
+
function defaultIsPidAlive(pid) {
|
|
92
|
+
try {
|
|
93
|
+
process.kill(pid, 0);
|
|
94
|
+
return true;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
return error?.code !== "ESRCH";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 文件刚好在 stat 前被释放/回收:返回 null → 调用方按「重试」处理。
|
|
101
|
+
async function statLock(filePath) {
|
|
102
|
+
try {
|
|
103
|
+
const stats = await fs.stat(filePath);
|
|
104
|
+
return { mtimeMs: stats.mtimeMs, size: stats.size };
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// link(2) 在「文件系统不支持硬链接」时的错误码。EPERM 也算:受限容器 / 开了
|
|
111
|
+
// fs.protected_hardlinks 的内核会用它拒绝 link,语义同样是「这条路走不通」,而不是「锁被占」。
|
|
112
|
+
const NO_HARDLINK_CODES = new Set(["ENOTSUP", "EOPNOTSUPP", "EPERM", "ENOSYS"]);
|
|
113
|
+
|
|
114
|
+
// 把锁推上锁路径。true = 独占拿到;false = EEXIST(别人持有 → 走争抢/回收判定)。
|
|
115
|
+
// 主路径 link(2)(锁文件恒完整);FS 不支持 hardlink 时降级到同步 O_EXCL 创建(见头注)。
|
|
116
|
+
async function publishLock({ tmpFile, lockFile, body }) {
|
|
117
|
+
try {
|
|
118
|
+
await fs.link(tmpFile, lockFile);
|
|
119
|
+
return true;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error?.code === "EEXIST") {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
if (!NO_HARDLINK_CODES.has(error?.code)) {
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
debugLog("config-lock.hardlink_unsupported", { lockFile, code: error.code });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let fd;
|
|
131
|
+
try {
|
|
132
|
+
fd = openSync(lockFile, "wx");
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error?.code === "EEXIST") {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
writeSync(fd, body);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
// body 没写成 → 锁路径上只剩一个我们自己刚创建的 0 字节文件。它归我们所有(O_EXCL 保证
|
|
143
|
+
// 没有第二个持有者),就地删掉,别把残骸留给下一个进程去超时。
|
|
144
|
+
rmSync(lockFile, { force: true });
|
|
145
|
+
throw error;
|
|
146
|
+
} finally {
|
|
147
|
+
closeSync(fd);
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function readOwner(filePath) {
|
|
153
|
+
try {
|
|
154
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
155
|
+
const parsed = JSON.parse(raw);
|
|
156
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
157
|
+
} catch {
|
|
158
|
+
// 不可读/解析失败(含 0 字节、半截读)→ null。
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 机会性清扫残留 tmp(Bug 7384601509):tmp 的生命周期原本只挂在「获取进程走完所有
|
|
164
|
+
// 出口」上——等待窗口内被 SIGINT/SIGTERM/SIGKILL/崩溃/断电终止的进程,其 tmp 因文件名
|
|
165
|
+
// 内嵌 randomUUID 永不复用而永久残留。这里在每次获取时顺带回收同目录的 `<lock>.tmp-*`
|
|
166
|
+
// 兄弟文件,判定与锁文件回收完全同一不变量:**可证明无主才删**(body 可读 + host 为
|
|
167
|
+
// 本机 + isPidAlive 判定 PID 不存在)。body 读不出的 tmp 一律不动(可能是正在写的活
|
|
168
|
+
// 等待者,误删会让其 fs.link 失败于 ENOENT 而不是 EEXIST,错误直接抛给调用方);异机
|
|
169
|
+
// host 不动(共享 HOME 场景不替别机做主)。清扫 best-effort:任何失败只留 debugLog,
|
|
170
|
+
// 绝不影响获取主流程。
|
|
171
|
+
async function sweepAbandonedTmpFiles(lockFile, { isPidAlive, ownTmp }) {
|
|
172
|
+
const dir = path.dirname(lockFile);
|
|
173
|
+
const prefix = `${path.basename(lockFile)}.tmp-`;
|
|
174
|
+
let entries;
|
|
175
|
+
try {
|
|
176
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (error?.code !== "ENOENT") {
|
|
179
|
+
debugLog("config-lock.tmp_sweep_failed", { lockFile, code: error?.code, message: error?.message });
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
for (const entry of entries) {
|
|
184
|
+
if (!entry.isFile()) continue;
|
|
185
|
+
const name = entry.name;
|
|
186
|
+
// ownTmp 显式跳过:调用方可能注入「一律判死」的 isPidAlive 测试桩,靠探活区分
|
|
187
|
+
// 不了自己的 tmp;误删自己的 tmp 会让 fs.link 失败于 ENOENT。
|
|
188
|
+
if (!name.startsWith(prefix) || name === ownTmp) continue;
|
|
189
|
+
const sibling = path.join(dir, name);
|
|
190
|
+
const owner = await readOwner(sibling);
|
|
191
|
+
if (!owner) continue; // body 读不出:无法证明无主,不动
|
|
192
|
+
if (owner.host !== os.hostname()) continue; // 异机无法探活,不动
|
|
193
|
+
// process.kill 接受正的 int32 PID;范围外的元数据不能作为死亡证明。
|
|
194
|
+
if (!Number.isInteger(owner.pid) || owner.pid <= 0 || owner.pid > 0x7fff_ffff) continue;
|
|
195
|
+
try {
|
|
196
|
+
if (isPidAlive(owner.pid)) continue; // 存活(含僵尸/PID 复用)一律不删
|
|
197
|
+
} catch (error) {
|
|
198
|
+
debugLog("config-lock.tmp_sweep_failed", { tmpFile: sibling, code: error?.code, message: error?.message });
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
await fs.rm(sibling, { force: true });
|
|
203
|
+
} catch (error) {
|
|
204
|
+
debugLog("config-lock.tmp_remove_failed", { tmpFile: sibling, code: error?.code, message: error?.message });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
debugLog("config-lock.tmp_swept", { tmpFile: sibling, pid: owner.pid });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 获取 `<targetPath>.lock` 跨进程锁。返回一个幂等的、ownership-checked 的 `release()`。
|
|
213
|
+
*
|
|
214
|
+
* 协议:body 先写进 `<lock>.tmp-<token>`,再 `fs.link(tmp, lockFile)` 原子上位(FS 不支持
|
|
215
|
+
* hardlink 时降级为同步 O_EXCL 创建,见头注)。上位
|
|
216
|
+
* 成功 = 独占持有;EEXIST = 别人持有 → 若锁 age 超过 staleMs 则判定 owner 是否可证明
|
|
217
|
+
* 无主(本机 host + kill(0) ESRCH),可证明才回收、回到原子 link 重新竞争;否则
|
|
218
|
+
* sleep(retry+jitter) 直到拿到或超时。超时抛 `CONFIG_LOCK_TIMEOUT`(调用方应把它当成
|
|
219
|
+
* 瞬时错误:不清盘、下轮重试),绝不静默降级成无锁写,以免重新引入覆盖竞态。
|
|
220
|
+
*
|
|
221
|
+
* @returns {Promise<() => Promise<void>>} release 函数
|
|
222
|
+
*/
|
|
223
|
+
export async function acquireConfigLock(targetPath, {
|
|
224
|
+
staleMs = DEFAULT_STALE_MS,
|
|
225
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
226
|
+
retryMs = DEFAULT_RETRY_MS,
|
|
227
|
+
now = Date.now,
|
|
228
|
+
sleep = defaultSleep,
|
|
229
|
+
isPidAlive = defaultIsPidAlive
|
|
230
|
+
} = {}) {
|
|
231
|
+
const lockFile = `${targetPath}.lock`;
|
|
232
|
+
const token = `${randomUUID()}.${process.pid}`;
|
|
233
|
+
const tmpFile = `${lockFile}.tmp-${token}`;
|
|
234
|
+
const body = JSON.stringify({ token, pid: process.pid, host: os.hostname(), acquiredAt: now() });
|
|
235
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
236
|
+
const deadline = now() + timeoutMs;
|
|
237
|
+
// 最后一次「探活后仍决定不回收」的观察。事后唯一能区分「慢临界区」和「真死锁」的证据。
|
|
238
|
+
let contention = null;
|
|
239
|
+
|
|
240
|
+
// 先把完整 body 落进 tmp(O_EXCL 的唯一 tmp 名,绝不撞车)。写失败只弄脏 tmp,
|
|
241
|
+
// 锁路径始终干净——绝不像旧实现那样在锁路径上留下 0 字节死锁残骸。
|
|
242
|
+
try {
|
|
243
|
+
await fs.writeFile(tmpFile, body, { encoding: "utf8", flag: "wx" });
|
|
244
|
+
} catch (error) {
|
|
245
|
+
await fs.rm(tmpFile, { force: true }).catch(() => {});
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
// 清扫处于自己的 tmp 清理保护区内,且失败不阻断锁获取。
|
|
251
|
+
await sweepAbandonedTmpFiles(lockFile, { isPidAlive, ownTmp: path.basename(tmpFile) }).catch((error) => {
|
|
252
|
+
debugLog("config-lock.tmp_sweep_failed", { lockFile, code: error?.code, message: error?.message });
|
|
253
|
+
});
|
|
254
|
+
for (;;) {
|
|
255
|
+
// 原子上位:lockFile 要么指向这个已写满的 inode,要么 EEXIST。link 之后
|
|
256
|
+
// lockFile 与 tmpFile 是同一 inode 的两个名字,删 tmp 不影响 lockFile。
|
|
257
|
+
if (!(await publishLock({ tmpFile, lockFile, body }))) {
|
|
258
|
+
// 已被持有。age 超过 staleMs 才做回收判定(阈值只是省 kill(0) 的触发器,
|
|
259
|
+
// 不是死亡判定)。
|
|
260
|
+
const stats = await statLock(lockFile);
|
|
261
|
+
if (stats !== null && now() - stats.mtimeMs > staleMs) {
|
|
262
|
+
const ageMs = now() - stats.mtimeMs;
|
|
263
|
+
const outcome = await reapIfOrphaned(lockFile, { isPidAlive });
|
|
264
|
+
if (outcome.reaped) {
|
|
265
|
+
// 回收成功不直接授予锁:回到原子上位与其他等待者公平竞争。
|
|
266
|
+
debugLog("config-lock.reaped", { lockFile, reason: outcome.reason, ageMs });
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
contention = { reason: outcome.reason, owner: outcome.owner, ageMs, size: stats.size };
|
|
270
|
+
// 只在 CODEM_DEBUG 下留痕。绝不能裸 console.warn:bridge 与交互式 linco TUI
|
|
271
|
+
// 共享 fd 2(linco-runner 用 stdio:"inherit" 起 TUI),TUI 运行期间父进程仍会
|
|
272
|
+
// 走这条路径写 config(如切空间)——一行诊断会直接刷进 ratatui 的 alternate
|
|
273
|
+
// screen 且用户无法消除。诊断信息不丢:同样的文本在超时错误的 message 里。
|
|
274
|
+
debugLog("config-lock.contended", { lockFile, ...contention });
|
|
275
|
+
}
|
|
276
|
+
if (now() >= deadline) {
|
|
277
|
+
// 超时时刻重读一次当前 owner:`contention` 可能是几秒前的观察(锁在期间已易主,
|
|
278
|
+
// 新持有者一直 fresh 就不再触发探活),直接拿它当「当前持有者」会指认一个早已退出
|
|
279
|
+
// 的 pid,把运维带去追错的进程。
|
|
280
|
+
const current = await readOwner(lockFile);
|
|
281
|
+
const timeout = new Error(
|
|
282
|
+
`config lock busy after ${timeoutMs}ms: ${lockFile} (${describeContention(contention, current)})`
|
|
283
|
+
);
|
|
284
|
+
timeout.code = "CONFIG_LOCK_TIMEOUT";
|
|
285
|
+
timeout.lockFile = lockFile;
|
|
286
|
+
timeout.lockOwner = current;
|
|
287
|
+
timeout.notReclaimedReason = contention?.reason ?? null;
|
|
288
|
+
throw timeout;
|
|
289
|
+
}
|
|
290
|
+
await sleep(jitter(retryMs));
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 上位成功 → 我们独占持有。
|
|
295
|
+
let released = false;
|
|
296
|
+
return async () => {
|
|
297
|
+
if (released) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
released = true;
|
|
301
|
+
// Ownership-checked release:token 匹配才删;不匹配/读不到 → no-op,
|
|
302
|
+
// 绝不移动或删除别人的锁。存活 holder 的锁只有自己会删(见头注不变量),
|
|
303
|
+
// 所以这里的 read→unlink 无竞态。
|
|
304
|
+
const owner = await readOwner(lockFile);
|
|
305
|
+
if (owner?.token === token) {
|
|
306
|
+
await fs.rm(lockFile, { force: true }).catch(() => {});
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
} finally {
|
|
311
|
+
// tmp 在所有出口(成功 return / 超时 throw / 其它 error)都不再需要:成功时
|
|
312
|
+
// lockFile 已是独立的第二个名字,删 tmp 只是丢一个多余的 link,inode 由 lockFile 存续。
|
|
313
|
+
// 删失败不再静默吞掉(残留 tmp 正是 Bug 7384601509 的累积源之一),与
|
|
314
|
+
// config-lock.hardlink_unsupported 同风格留痕,便于事后定位。
|
|
315
|
+
await fs.rm(tmpFile, { force: true }).catch((error) => {
|
|
316
|
+
debugLog("config-lock.tmp_remove_failed", { tmpFile, code: error?.code, message: error?.message });
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// orphan 回收:仅当 owner host 为本机且 PID 明确不存在(ESRCH)时 unlink——这是原始的、
|
|
322
|
+
// 唯一可证明无主的回收路径,未作任何放松。unlink 前复核 token 未变(两个回收者竞争同一
|
|
323
|
+
// 具尸体时,输家看到新 holder 的 token 即中止)。
|
|
324
|
+
//
|
|
325
|
+
// 返回 { reaped, reason, owner }。**每条「不回收」的分支都必须带上 reason** —— 这些分支
|
|
326
|
+
// 合起来就是「锁永不回收」的全部入口,一旦命中就是人工 rm 才能解的死锁,而过去它们只
|
|
327
|
+
// 返回一个哑 boolean:超时错误里没有 owner、没有 age、没有判定理由,事后除了猜没有任何
|
|
328
|
+
// 可查的东西(线上曾出现 411 次同一行零信息量的 `config lock busy`,现场被 rm 掉后根因
|
|
329
|
+
// 永久不可判定)。reason 会进超时错误。
|
|
330
|
+
async function reapIfOrphaned(lockFile, { isPidAlive }) {
|
|
331
|
+
const owner = await readOwner(lockFile);
|
|
332
|
+
if (!owner) {
|
|
333
|
+
// 元数据不可读:0 字节(升级前旧版残骸)/ 半截 / 外部工具写的别的格式。无法证明
|
|
334
|
+
// 无主——尤其 rolling upgrade 期间旧版进程可能瞬时留下 0 字节锁——一律按存活处理。
|
|
335
|
+
return { reaped: false, reason: "owner_unreadable", owner: null };
|
|
336
|
+
}
|
|
337
|
+
if (owner.host !== os.hostname()) {
|
|
338
|
+
return { reaped: false, reason: "foreign_host", owner }; // 异机无法探活
|
|
339
|
+
}
|
|
340
|
+
if (!Number.isInteger(owner.pid) || owner.pid <= 0) {
|
|
341
|
+
return { reaped: false, reason: "invalid_pid", owner };
|
|
342
|
+
}
|
|
343
|
+
if (isPidAlive(owner.pid)) {
|
|
344
|
+
// 核心不变量:存活进程持有的锁绝不回收。注意 kill(pid,0) 对「僵尸进程」和
|
|
345
|
+
// 「PID 已被复用」同样返回存活 —— 那类边界宁可延迟释放,也不误回收。
|
|
346
|
+
return { reaped: false, reason: "owner_alive", owner };
|
|
347
|
+
}
|
|
348
|
+
const releaseReaper = await tryAcquireReaperElection(lockFile, { isPidAlive });
|
|
349
|
+
if (!releaseReaper) {
|
|
350
|
+
return { reaped: false, reason: "reap_in_progress", owner };
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
// 与 App 共享该选举协议;没有它,另一个 waiter 可在复核后误删新 holder。
|
|
354
|
+
const recheck = await readOwner(lockFile);
|
|
355
|
+
if (!recheck || recheck.token !== owner.token) {
|
|
356
|
+
return { reaped: false, reason: "owner_changed", owner };
|
|
357
|
+
}
|
|
358
|
+
await fs.rm(lockFile, { force: true }).catch(() => {});
|
|
359
|
+
return { reaped: true, reason: "dead_owner", owner };
|
|
360
|
+
} finally {
|
|
361
|
+
await releaseReaper();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// 单次 Bakery 选举:choosing 先声明进入取号门区,ticket 决定全序;失败者退出后由外层
|
|
366
|
+
// 带抖动重试。每个目录名包含唯一 token,所以清理死亡 PID 的候选不会命中新候选。
|
|
367
|
+
async function tryAcquireReaperElection(lockFile, { isPidAlive }) {
|
|
368
|
+
const reaperDir = `${lockFile}.reap`;
|
|
369
|
+
const candidateId = `${reaperHostDigest()}.${process.pid}.${randomUUID()}`;
|
|
370
|
+
const choosingPath = path.join(reaperDir, `${REAPER_CHOOSING_PREFIX}${candidateId}`);
|
|
371
|
+
let ticketPath = null;
|
|
372
|
+
let acquired = false;
|
|
373
|
+
|
|
374
|
+
await fs.mkdir(reaperDir, { recursive: true });
|
|
375
|
+
await fs.mkdir(choosingPath);
|
|
376
|
+
try {
|
|
377
|
+
try {
|
|
378
|
+
const observed = await scanReaperCandidates(reaperDir, REAPER_TICKET_PREFIX, { isPidAlive });
|
|
379
|
+
if (observed.blocked) {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
const ticket = observed.candidates.reduce((maximum, entry) => Math.max(maximum, entry.ticket), 0) + 1;
|
|
383
|
+
if (!Number.isSafeInteger(ticket)) return null;
|
|
384
|
+
ticketPath = path.join(
|
|
385
|
+
reaperDir,
|
|
386
|
+
`${REAPER_TICKET_PREFIX}${String(ticket).padStart(REAPER_TICKET_WIDTH, "0")}-${candidateId}`
|
|
387
|
+
);
|
|
388
|
+
await fs.mkdir(ticketPath);
|
|
389
|
+
} finally {
|
|
390
|
+
await fs.rm(choosingPath, { recursive: true, force: true });
|
|
391
|
+
}
|
|
392
|
+
const choosing = await scanReaperCandidates(reaperDir, REAPER_CHOOSING_PREFIX, { isPidAlive });
|
|
393
|
+
if (choosing.blocked || choosing.candidates.length > 0) {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
const tickets = await scanReaperCandidates(reaperDir, REAPER_TICKET_PREFIX, { isPidAlive });
|
|
397
|
+
const own = tickets.candidates.find((entry) => entry.candidateId === candidateId);
|
|
398
|
+
if (tickets.blocked || !own) {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
const hasPredecessor = tickets.candidates.some((entry) =>
|
|
402
|
+
entry.ticket < own.ticket ||
|
|
403
|
+
(entry.ticket === own.ticket && entry.candidateId < candidateId)
|
|
404
|
+
);
|
|
405
|
+
if (hasPredecessor) {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
acquired = true;
|
|
409
|
+
let released = false;
|
|
410
|
+
return async () => {
|
|
411
|
+
if (released) return;
|
|
412
|
+
released = true;
|
|
413
|
+
await fs.rm(ticketPath, { recursive: true, force: true });
|
|
414
|
+
};
|
|
415
|
+
} finally {
|
|
416
|
+
if (!acquired) {
|
|
417
|
+
try {
|
|
418
|
+
await fs.rm(choosingPath, { recursive: true, force: true });
|
|
419
|
+
} finally {
|
|
420
|
+
if (ticketPath) await fs.rm(ticketPath, { recursive: true, force: true });
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function scanReaperCandidates(reaperDir, prefix, { isPidAlive }) {
|
|
427
|
+
let entries;
|
|
428
|
+
try {
|
|
429
|
+
entries = await fs.readdir(reaperDir, { withFileTypes: true });
|
|
430
|
+
} catch (error) {
|
|
431
|
+
if (error?.code === "ENOENT") {
|
|
432
|
+
return { blocked: false, candidates: [] };
|
|
433
|
+
}
|
|
434
|
+
throw error;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const candidates = [];
|
|
438
|
+
let blocked = false;
|
|
439
|
+
for (const entry of entries) {
|
|
440
|
+
if (!entry.name.startsWith(prefix)) continue;
|
|
441
|
+
// A reserved candidate name with an unexpected file type is not proof of
|
|
442
|
+
// an absent contender. Preserve it and refuse to reclaim the lock.
|
|
443
|
+
if (!entry.isDirectory()) {
|
|
444
|
+
blocked = true;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
const parsed = parseReaperCandidate(entry.name, prefix);
|
|
448
|
+
if (!parsed) {
|
|
449
|
+
blocked = true;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (parsed.hostDigest === reaperHostDigest() && parsed.pid !== process.pid && !isPidAlive(parsed.pid)) {
|
|
453
|
+
await fs.rm(path.join(reaperDir, entry.name), { recursive: true, force: true });
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
candidates.push(parsed);
|
|
457
|
+
}
|
|
458
|
+
return { blocked, candidates };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function reaperHostDigest() {
|
|
462
|
+
return createHash("sha256").update(os.hostname(), "utf8").digest("hex");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function parseReaperCandidate(name, prefix) {
|
|
466
|
+
let ticket = 0;
|
|
467
|
+
let candidateId;
|
|
468
|
+
if (prefix === REAPER_CHOOSING_PREFIX) {
|
|
469
|
+
candidateId = name.slice(prefix.length);
|
|
470
|
+
} else {
|
|
471
|
+
const match = /^ticket-(\d+)-(.+)$/.exec(name);
|
|
472
|
+
if (!match) return null;
|
|
473
|
+
ticket = Number(match[1]);
|
|
474
|
+
candidateId = match[2];
|
|
475
|
+
if (!Number.isSafeInteger(ticket) || ticket <= 0) return null;
|
|
476
|
+
}
|
|
477
|
+
const identity = /^([a-f0-9]{64})\.([1-9]\d*)\.([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/.exec(candidateId);
|
|
478
|
+
if (!identity) return null;
|
|
479
|
+
const pid = Number(identity[2]);
|
|
480
|
+
if (!Number.isSafeInteger(pid)) return null;
|
|
481
|
+
return { candidateId, hostDigest: identity[1], pid, ticket };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const NOT_RECLAIMED_REASONS = {
|
|
485
|
+
owner_unreadable: "lock metadata is empty or unparseable, so it cannot be proven ownerless (a 0-byte lock is left behind by a pre-upgrade crash and must be removed by hand)",
|
|
486
|
+
foreign_host: "lock was taken on another host, so liveness cannot be probed",
|
|
487
|
+
invalid_pid: "lock metadata carries no usable pid",
|
|
488
|
+
owner_alive: "owner process is still alive (note: kill(pid,0) also reports alive for a zombie or a recycled pid)",
|
|
489
|
+
reap_in_progress: "another process is already rechecking and reclaiming this dead owner's lock",
|
|
490
|
+
owner_changed: "lock changed hands while probing the owner"
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
// 渲染一行可诊断的文本。`observation` 是最后一次「判定不回收」的观察(可能已过期),
|
|
494
|
+
// `current` 是超时时刻重读到的持有者 —— 两者分开报,别把陈旧观察当成当前持有者。
|
|
495
|
+
function describeContention(observation, current) {
|
|
496
|
+
const holder = current
|
|
497
|
+
? `held by pid=${current.pid ?? "?"} host=${current.host ?? "?"}`
|
|
498
|
+
: "holder unreadable or already gone at timeout";
|
|
499
|
+
|
|
500
|
+
if (!observation) {
|
|
501
|
+
// 从未过龄 → 从未探活:短临界区的正常争抢,别报成卡死锁。
|
|
502
|
+
return `${holder}; lock stayed fresh throughout (never aged past staleMs) — likely normal contention, not a stuck lock`;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const { owner, reason, ageMs, size } = observation;
|
|
506
|
+
const probed = owner
|
|
507
|
+
? `pid=${owner.pid ?? "?"} host=${owner.host ?? "?"}`
|
|
508
|
+
: `owner=<unparseable> size=${size ?? "?"}B`;
|
|
509
|
+
const age = Number.isFinite(ageMs) ? ` age=${(ageMs / 1000).toFixed(1)}s` : "";
|
|
510
|
+
const why = NOT_RECLAIMED_REASONS[reason] ?? reason ?? "unknown";
|
|
511
|
+
return `${holder}; last probe saw ${probed}${age} and did not reclaim because ${why}`;
|
|
512
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function credentialStorageRecovery(errorCode) {
|
|
2
|
+
const code = String(errorCode).replace(/^credential_storage_/, "");
|
|
3
|
+
if (code === "backend_load_failed") return {
|
|
4
|
+
message: "本机凭据模块无法加载",
|
|
5
|
+
hint: "请重新安装与当前操作系统和架构匹配的完整 CodeM 包;保留现有凭据文件,不要删除或重新生成主密钥。"
|
|
6
|
+
};
|
|
7
|
+
if (code !== "corrupt" && code !== "key_missing") return null;
|
|
8
|
+
return {
|
|
9
|
+
message: code === "corrupt" ? "本机加密凭据损坏" : "本机凭据主密钥缺失",
|
|
10
|
+
hint: "请运行 codem report --dry-run --no-session 生成本地诊断包;如有备份,请恢复配套的凭据和主密钥。不要单独删除或重新生成主密钥。"
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class CredentialStorageError extends Error {
|
|
15
|
+
constructor(code, kind = null) {
|
|
16
|
+
const recovery = credentialStorageRecovery(code);
|
|
17
|
+
super(`Credential storage ${code}${kind ? ` (${kind})` : ""}${recovery ? `。${recovery.message}。${recovery.hint}` : ""}`);
|
|
18
|
+
this.name = "CredentialStorageError";
|
|
19
|
+
this.code = `credential_storage_${code}`;
|
|
20
|
+
this.kind = kind;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isRecoverableCredentialState(error) {
|
|
25
|
+
return error?.code === "credential_storage_rotation_pending"
|
|
26
|
+
|| error?.code === "credential_storage_rotation_unknown";
|
|
27
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { CredentialStorageError } from "./credential-errors.js";
|
|
3
|
+
|
|
4
|
+
export const CREDENTIAL_SCHEMA = 1;
|
|
5
|
+
const KINDS = new Set(["oauth", "router-ak"]);
|
|
6
|
+
const STATES = new Set(["legacy", "preparing", "secure"]);
|
|
7
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
|
|
8
|
+
function fail(code, kind) { throw new CredentialStorageError(code, kind); }
|
|
9
|
+
|
|
10
|
+
export async function readCredentialMetadata(metadataPath, marker = null, expectedKind = null) {
|
|
11
|
+
if (expectedKind && marker && marker.kind !== expectedKind) fail("scope_mismatch", expectedKind);
|
|
12
|
+
let metadata;
|
|
13
|
+
try {
|
|
14
|
+
metadata = JSON.parse(await fs.readFile(metadataPath, "utf8"));
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (error.code === "ENOENT" && !marker) return null;
|
|
17
|
+
fail(error.code === "ENOENT" ? "metadata_missing" : "metadata_unreadable");
|
|
18
|
+
}
|
|
19
|
+
if (metadata?.schemaVersion !== CREDENTIAL_SCHEMA) fail("incompatible");
|
|
20
|
+
if (!UUID.test(metadata.scopeId) || !UUID.test(metadata.loginEpoch)
|
|
21
|
+
|| typeof metadata.loggedOut !== "boolean" || !metadata.slots || typeof metadata.slots !== "object") fail("corrupt");
|
|
22
|
+
for (const [kind, slot] of Object.entries(metadata.slots)) {
|
|
23
|
+
if (!KINDS.has(kind) || !STATES.has(slot?.state)) fail("corrupt", kind);
|
|
24
|
+
if (slot.state !== "legacy" && typeof slot.backend !== "string") fail("corrupt", kind);
|
|
25
|
+
if (slot.rotation && !UUID.test(slot.rotation.operationId)) fail("corrupt", kind);
|
|
26
|
+
}
|
|
27
|
+
if (marker && (marker.schemaVersion !== CREDENTIAL_SCHEMA || marker.scopeId !== metadata.scopeId)) fail("scope_mismatch");
|
|
28
|
+
return metadata;
|
|
29
|
+
}
|