@noob-stupid/dsh-plugin-console 0.5.101 → 0.5.103

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.
@@ -12,10 +12,13 @@ import { cleanupStalePackageDir } from '../infra/fsx.js'
12
12
  import { alreadyInstalledTarget } from './install-verify.js'
13
13
  import { noteChannel, tryGitChannel } from './git-channel.js'
14
14
  import { retryPnpmOnce } from './install-diagnose.js'
15
+ import { INSTALL_LOCK_TIMEOUT, withInstallLock } from './install-lock.js'
15
16
  import { targetVersionOf, versionMissOf, versionMissText } from './install-version.js'
16
17
  import { tryPresetSourceChannel } from './preset-source.js'
17
18
  // 自更新兜底(2026-11-03 加法,见 domain/self-update-guard.js 顶部事故记录):只在"装的包名 === 本插件自己"时接管
18
19
  import { beginSelfUpdateGuard, selfUpdateFailureText, settleSelfUpdateGuard } from './self-update-guard.js'
20
+ // 通用"超时/中断把目录弄坏 ⇒ 复核 + 恢复"(2026-11-04 加法,见 domain/install-recovery.js 顶部):对**所有**包生效
21
+ import { withInstallRecovery } from './install-recovery.js'
19
22
 
20
23
  /** release 通道的候选预算(issue #3):release 通道现在按包名反查发布仓库,**每个候选**至少一次
21
24
  * `releases?per_page=10` 调用(第一梯队仓库没命中还要多试几个候选仓库),而候选可能有十几个
@@ -24,11 +27,17 @@ import { beginSelfUpdateGuard, selfUpdateFailureText, settleSelfUpdateGuard } fr
24
27
  const RELEASE_CHANNEL_BUDGET = 3
25
28
 
26
29
  /**
27
- * 单个候选的通道序列**外壳**(2026-11-03 加法):只给"本插件自己的自更新"多加一层备份/校验/自愈,
28
- * 其余情况(所有别的包)**原样直通**——`beginSelfUpdateGuard` 不成立就 `return tryCandidateChannelsInner(...)`,
29
- * 一个字节都不多走(tests/test-self-update-guard.mjs ⑤ 用"零备份 + 无 selfUpdate 字段 + 通道调用顺序不变"钉住)。
30
+ * 单个候选的通道序列**外壳**(2026-11-04 起有两个壳,顺序如下):
31
+ * ① 最外层 = **互斥**(domain/install-lock.js):同一个 `profile + 包名` 同一时刻只允许一个安装作业,
32
+ * 第二个排队等待(不同包照旧并行)。为什么放在最外层:整套通道(竞速 / 串行 pnpm / curl / release /
33
+ * git / 定向重试)动的都是**同一个** `node_modules/<包名>` 目录 —— 真机 2026-11-04 的事故就是
34
+ * 两个 pnpm 同时装同一个包,pnpm 把正式目录改名成 `_tmp_` 后进程被杀,正式目录直接空掉。
35
+ * ② 里层 = **自更新兜底**(2026-11-03 加法):只给"本插件自己的自更新"多加一层备份/校验/自愈,
36
+ * 其余情况(所有别的包)**原样直通** —— `beginSelfUpdateGuard` 不成立就走
37
+ * `withInstallRecovery(...)`(通用恢复,见下),自更新那条路一个字节都不多走
38
+ * (tests/test-self-update-guard.mjs ⑤ 用"零备份 + 无 selfUpdate 字段 + 通道调用顺序不变"钉住)。
30
39
  *
31
- * 为什么包在整条序列外面,而不是包在某一次 pnpm 调用里面:真机事故(宿主读着插件自己的文件 ⇒ EPERM、
40
+ * 为什么自更新兜底包在整条序列外面,而不是包在某一次 pnpm 调用里面:真机事故(宿主读着插件自己的文件 ⇒ EPERM、
32
41
  * Windows 上 pnpm 先删旧目录再解包、删到一半被打断)里,**残缺态是整条序列结束时才看得见的**,
33
42
  * 而"哪条通道声称装上了"完全不可信(那一次 curl/pnpm 都回了成功)。所以:
34
43
  * ① 进来先备份(复制,不打扰正在运行的目录);
@@ -37,13 +46,50 @@ const RELEASE_CHANNEL_BUDGET = 3
37
46
  * 由 install-job.js 的自更新分支收口成可见失败(绝不报"更新成功")。
38
47
  */
39
48
  async function tryCandidateChannels(args) {
49
+ const packageName = typeof args?.name === 'string' ? args.name : ''
50
+ const profileDir = typeof args?.profileDir === 'string' ? args.profileDir : ''
51
+ try {
52
+ return await withInstallLock({
53
+ profileDir, packageName, job: args?.job ?? null,
54
+ // 排队上限默认 10 分钟(domain/install-lock.js#INSTALL_LOCK_WAIT_MS);单测可传更小的值
55
+ ...(Number.isFinite(args?.lockWaitMs) ? { waitMs: args.lockWaitMs } : {}),
56
+ // 排队时留一行可读日志(面板的「通道备注」里看得到"在等谁")
57
+ onWait: ({ holder }) => {
58
+ if (args?.job !== null && args?.job !== undefined && typeof args.job === 'object') {
59
+ noteChannel(args.job, `同一个包正在被另一个作业安装${holder?.jobId ? `(${holder.jobId})` : ''}:本次排队等待,不会同时动 node_modules/${packageName}`)
60
+ }
61
+ },
62
+ }, async () => await runSelfUpdateGuarded(args))
63
+ } catch (error) {
64
+ if (error?.code !== INSTALL_LOCK_TIMEOUT) throw error
65
+ // 排队超时 ⇒ 如实"没装成"(不改既有通道语义:调用方本来就按 installedName=null 处理失败)
66
+ if (args?.job !== null && args?.job !== undefined && typeof args.job === 'object') {
67
+ args.job.installLock = { ...(args.job.installLock ?? {}), ...(error.holder ?? {}), timeout: true, reason: error.message }
68
+ }
69
+ return { installedName: null, lastError: error }
70
+ }
71
+ }
72
+
73
+ /** 自更新兜底那一层(原 tryCandidateChannels 的正文,逐字保留)—— 见上方 ② 的说明。 */
74
+ async function runSelfUpdateGuarded(args) {
40
75
  const inner = () => tryCandidateChannelsInner(args)
41
76
  const guardDeps = args?.selfUpdateDeps ?? {}
42
77
  const session = beginSelfUpdateGuard({
43
78
  packageName: args?.name, profileDir: args?.profileDir,
44
79
  targetVersion: targetVersionOf(args?.job, args?.name), deps: guardDeps,
45
80
  })
46
- if (session === null) return inner()
81
+ // ★ 2026-11-04 加法(见 domain/install-recovery.js 顶部现场):不是自更新 ⇒ 走**通用**恢复那一层 ——
82
+ // 对所有包生效(真机清空的不止控制台:用户的 dsh-workbuddy-connect 也被清空过)。
83
+ // 为什么二选一、不叠加:两层用的都是同一个备份后缀(`.pre-update-<ts>`),叠在一起会互相把对方
84
+ // 刚建好的那份 prune 掉;而自更新那条路的判据更强(还核对版本与入口文件),由它自己负责到底。
85
+ // `run(null)` = 没接管 ⇒ 用**原来的**通道对象跑(一个字节都不多走);接管时换成"被杀即复核"的代理。
86
+ if (session === null) {
87
+ const runWith = (channels) => tryCandidateChannelsInner(channels === null || channels === undefined ? args : { ...args, ch: channels })
88
+ return await withInstallRecovery({
89
+ job: args?.job ?? null, name: args?.name, profileDir: args?.profileDir,
90
+ targetVersion: targetVersionOf(args?.job, args?.name), ch: args?.ch ?? null, deps: args?.recoveryDeps ?? {},
91
+ }, runWith)
92
+ }
47
93
  let outcome = null
48
94
  let thrown = null
49
95
  try {
@@ -12,11 +12,19 @@ import { buildPnpmEnv, runPnpmWithFallback } from '../infra/exec.js'
12
12
  /** 安装失败清场:把本次尝试过的候选包目录与 pnpm `_tmp_` 半成品一起清掉,并**如实汇报**清了什么、什么没清掉
13
13
  * (2026-09-20 真装演练:聚合仓库跑 19 分钟后失败,node_modules 留着 `…_tmp_56272_2` 半成品,面板只说"安装失败")。
14
14
  * 2026-09-26(改错):删除一律走 disposeDir(本机 C 盘/`%TEMP%` 下 rmSync 会静默落空,不核实就会谎报干净);
15
- * 删不掉时它**改名降级成同父目录的 `.trash-<ts>`**(做法借自 2BingLing/dsh-market 的 `.bak-<ts>` + renameSync)。 */
16
- function cleanupAttemptedCandidates(profileDir, candidates) {
15
+ * 删不掉时它**改名降级成同父目录的 `.trash-<ts>`**(做法借自 2BingLing/dsh-market 的 `.bak-<ts>` + renameSync)。
16
+ *
17
+ * ★ 2026-11-04 加法(真机:超时之后正式目录被清空,用户面对的是"面板说失败、插件也真没了"):
18
+ * 第 3 个参数 `{ protect }` 列出**本次绝对不许清**的包名 —— 那些目录里放的是
19
+ * "domain/install-recovery.js 刚从更新前备份恢复回来的可用旧版本",清掉它才是真损失。
20
+ * 不传(老调用点、老测试)⇒ 一个字节都不变;被保护项单独汇报在 `protected` 里,不混进 cleaned。
21
+ */
22
+ function cleanupAttemptedCandidates(profileDir, candidates, { protect = [] } = {}) {
17
23
  const cleaned = []
18
24
  const failed = []
19
25
  const trashed = []
26
+ const protectedNames = []
27
+ const keep = new Set(Array.isArray(protect) ? protect.filter((n) => typeof n === 'string' && n !== '') : [])
20
28
  const dispose = (label, dir) => {
21
29
  const result = disposeDir(dir)
22
30
  if (result.trashed === true) trashed.push({ name: label, path: dir, trashPath: result.trashPath, note: disposeNote(result) })
@@ -25,6 +33,10 @@ function cleanupAttemptedCandidates(profileDir, candidates) {
25
33
  return 0
26
34
  }
27
35
  for (const name of candidates) {
36
+ if (keep.has(name)) {
37
+ protectedNames.push(name)
38
+ continue
39
+ }
28
40
  const dir = join(profileDir, 'node_modules', ...String(name).split('/'))
29
41
  const parent = dirname(dir)
30
42
  const base = basename(dir)
@@ -38,7 +50,7 @@ function cleanupAttemptedCandidates(profileDir, candidates) {
38
50
  } catch {}
39
51
  if (touched > 0) cleaned.push(name)
40
52
  }
41
- return { cleaned, failed, trashed }
53
+ return { cleaned, failed, trashed, protected: protectedNames }
42
54
  }
43
55
 
44
56
  /** 授权被拒/超时后的失败文案(纯函数,单测覆盖):说清为什么失败、清理了什么、什么没清掉。
@@ -52,13 +64,18 @@ function aiConsentFailureText(decision, leftovers, timeoutMs = AI_CONSENT_TIMEOU
52
64
  const cleaned = leftovers?.cleaned ?? []
53
65
  const failed = leftovers?.failed ?? []
54
66
  const trashed = leftovers?.trashed ?? []
67
+ const protectedList = Array.isArray(leftovers?.protected) ? leftovers.protected : []
55
68
  const noteOf = (item) => (typeof item?.note === 'string' && item.note !== '' ? `(${item.note})` : (item?.error != null ? `(${item.error})` : ''))
56
69
  const cleanedNote = cleaned.length > 0 ? `;已清理本次落盘残留:${cleaned.join('、')}` : ''
70
+ // 2026-11-04 加法:被保护的项要**说出来**(否则用户以为我们忘了清场)
71
+ const protectedNote = protectedList.length > 0
72
+ ? `;已保留 ${protectedList.join('、')} 不清场 —— 那是安装过程中被弄坏后、刚从备份恢复回来的旧版本,删掉它才是真损失`
73
+ : ''
57
74
  const trashedNote = trashed.length > 0
58
75
  ? `;有 ${trashed.length} 项正被占用、已改名降级为 .trash-*(${trashed.map((t) => (typeof t?.note === 'string' && t.note !== '' ? t.note : basename(String(t?.trashPath ?? '')))).join('、')})`
59
76
  : ''
60
77
  const failedNote = failed.length > 0 ? `;**有 ${failed.length} 项没能清理**:${failed.map((f) => `${f.path}${noteOf(f)}`).join('、')}` : ''
61
- return `${base}${cleanedNote}${trashedNote}${failedNote}`
78
+ return `${base}${cleanedNote}${protectedNote}${trashedNote}${failedNote}`
62
79
  }
63
80
 
64
81
  /** 用 corepack pnpm 移除包(与安装同一管理器与凭据抑制环境)。
@@ -92,6 +92,16 @@ const DIAGNOSIS_RULES = [
92
92
  hint: 'registry 上没有这个包/这个版本(包名拼错、子包未发布,或镜像未同步):核对包名与版本后重试;'
93
93
  + '若确认已发布,多半是镜像未同步完整 —— 换一个 registry 或稍后再试。',
94
94
  },
95
+ {
96
+ // 2026-11-04 加法:**同一个包正被另一个作业装着**(并发互斥闸拦下的那一次,见 domain/install-lock.js)。
97
+ // 必须排在 network-timeout 之前:这段文案里有"排队等待…超时"这种字样,落进网络分类会把用户带到
98
+ // "换源/稍后重试网络"的错误方向 —— 而真相是"等前一个作业跑完就能装,重试网络毫无意义"。
99
+ kind: 'package-locked',
100
+ re: /正在安装中|INSTALL_LOCK_TIMEOUT|排队等待/iu,
101
+ retry: null,
102
+ hint: '这个包正被另一个作业安装/更新:本控制台已保证**同一时刻只有一个作业动同一个包**,所以这次没有启动任何安装、'
103
+ + '也没有改动磁盘 —— 等那边结束后重试即可(不是网络问题,也不是包本身有问题)。',
104
+ },
95
105
  {
96
106
  kind: 'locked',
97
107
  re: /EPERM|EACCES|EBUSY|ERR_PNPM_EEXIST|operation not permitted|being used by another process/iu,
@@ -19,6 +19,8 @@ import { verifyInstalledEntry } from './install-verify.js'
19
19
  // 自更新兜底(2026-11-03 加法,见 domain/self-update-guard.js 顶部):「自更新没落地」的那句话由它产出,
20
20
  // 与通道层的 selfUpdate 报告同一份文案,别在作业里另写一套。
21
21
  import { selfUpdateFailureText } from './self-update-guard.js'
22
+ // 通用超时/中断恢复(2026-11-04 加法,见 domain/install-recovery.js 顶部):「安装把目录弄坏了」的那句话由它产出
23
+ import { installRecoveryFailureText } from './install-recovery.js'
22
24
  import { applyNpmNameHint, composeProbeFailureText, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubdirPlugins, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
23
25
  import { decideNoRootManifest } from './subdir-detect.js'
24
26
  import { runSubdirInstallJob } from './subdir-install.js'
@@ -277,6 +279,9 @@ async function runInstallJob(job, ports, deps = {}) {
277
279
  const attempt = await tryCandidateChannels({
278
280
  job, ch, name, profileDir, registries, repoChannelAllowed, budget,
279
281
  baseUrl: ports.baseUrl ?? null, expanded, deadline, sourceDeps,
282
+ // 包互斥闸的排队上限(domain/install-lock.js#INSTALL_LOCK_WAIT_MS 是生产默认值;
283
+ // 这里只是**单测注入缝**,生产调用方不传 deps.installLockWaitMs ⇒ undefined ⇒ 默认值)
284
+ lockWaitMs: Number.isFinite(deps.installLockWaitMs) ? deps.installLockWaitMs : undefined,
280
285
  expand: (expanded || job.npmNameHint !== undefined) ? null : expandSubpackages,
281
286
  })
282
287
  installedName = attempt.installedName
@@ -323,6 +328,18 @@ async function runInstallJob(job, ports, deps = {}) {
323
328
  return
324
329
  }
325
330
  if (installedName === null) {
331
+ // ★ 2026-11-04 加法(真机:两个 pnpm 同时装同一个包 ⇒ 正式目录被清空):**拿不到包互斥锁** ⇒ 如实失败,
332
+ // 且**不进 AI 兜底**。理由与自更新/版本闸两条同源:这不是"哪一步坏了",而是"这个包正被另一个作业装着",
333
+ // 让模型去"修"只会去动一个正被别人写着的目录(必然 EPERM,还要等 10 分钟授权)。
334
+ // `job.installLock.timeout` 只由 domain/install-lock.js 写入 ⇒ 非并发场景一个字节都不受影响。
335
+ if (job.installLock !== null && job.installLock !== undefined && job.installLock.timeout === true) {
336
+ job.status = 'failed'
337
+ job.stage = 'install-lock'
338
+ job.error = job.installLock.reason ?? `这个包正在安装中(${job.packageName ?? job.candidateName ?? '未知包'}),本次排队等待超时,已放弃`
339
+ job.lastError = job.error
340
+ job.diagnosis = { kind: 'package-locked', hint: '同一个包同一时刻只允许一个安装作业:等前一个结束后重试即可(不是网络问题)。', retry: null, packages: [job.packageName ?? job.candidateName].filter((n) => typeof n === 'string' && n !== '') }
341
+ return
342
+ }
326
343
  if (job.diagnosis == null) job.diagnosis = classifyInstallFailure(lastError?.message) // 分类提示兜底(加法)
327
344
  // 本地 AI 兜底会调用模型 API、产生费用:挂起等待用户明确同意后再执行
328
345
  job.stage = 'ai-consent'
@@ -358,15 +375,23 @@ async function runInstallJob(job, ports, deps = {}) {
358
375
  job.status = 'failed'
359
376
  // 失败/取消时清场并如实汇报(见 cleanupAttemptedCandidates 注释):
360
377
  // 不能让用户面对"面板说失败、磁盘上却留了半个包和 _tmp_ 残留"的糊涂账。
361
- const leftovers = cleanupAttemptedCandidates(profileDir, candidates)
378
+ // ★ 2026-11-04 加法:**恢复回来的那份不许清**(domain/install-recovery.js 刚把更新前的备份放回原位,
379
+ // 那一刻盘上是用户**现在还能用**的旧版本;旧代码在这里把它一起删掉 —— 真机"正式目录被清空且再也回不来"
380
+ // 的最后一刀就在这一行)。保护名单只来自本作业自己的恢复报告,其余候选照旧清场。
381
+ const protect = job.installRecovery !== null && job.installRecovery !== undefined && job.installRecovery.healed === true
382
+ && typeof job.installRecovery.packageName === 'string' ? [job.installRecovery.packageName] : []
383
+ const leftovers = cleanupAttemptedCandidates(profileDir, candidates, { protect })
362
384
  job.leftovers = leftovers
363
385
  // ★ 失败路径 B 的文案(0.5.41 加法):**只有如实失败 + 原因**。取样/下载本身失败时磁盘上
364
386
  // 什么都没有 ⇒ 没有任何东西可挂 ⇒ 面板**绝不**出现"回落成功 / 已安装 / 已启用"之类说法。
365
387
  // 这里把最后一次的真实失败原因接在既有文案之后(不改写、不掩盖),并保留既有诊断分类(可重试动作
366
388
  // 由面板的「重试」按钮与 diagnosis.hint 承担)。
367
389
  const reason = typeof lastError?.message === 'string' && lastError.message !== '' ? lastError.message : null
390
+ // ★ 2026-11-04 加法:恢复/校验的结论单独接一句(绝不与"装上了"混在一起)
391
+ const recoveryNote = installRecoveryFailureText(job.installRecovery)
368
392
  job.error = aiConsentFailureText(decision, leftovers, aiConsentTimeoutMs)
369
393
  + (reason === null ? '' : `;最后一次失败原因:${reason}`)
394
+ + (recoveryNote === null ? '' : `;${recoveryNote}`)
370
395
  }
371
396
  return
372
397
  }
@@ -0,0 +1,225 @@
1
+ // L1 · domain —— install-lock.js(**同一个 profile + 包名** 的安装互斥;2026-11-04 加法)
2
+ // 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言;不认识 cordis ctx)
3
+ //
4
+ // ── 现场(真机 2026-11-04,用户提供的原始日志)────────────────────────────────────
5
+ // 一次「详情页更新控制台自己」失败,日志里 pnpm 先被 180s 超时杀树,随后:
6
+ // [WARN] Moving @noob-stupid/dsh-plugin-console_tmp_13796_2 to node_modules\.ignored\…
7
+ // [WARN] Moving dsh-workbuddy-connect_tmp_13796_3 to node_modules\.ignored\…
8
+ // 后果:正式目录被清空(只剩 1 个文件、package.json 不见),`node_modules/.ignored` 堆到 118 项 / 335 MB。
9
+ // 用户的判断(原文要点):「这是**两个 pnpm 安装同时装同一个包**造成的冲突 —— 并行竞速本身没问题,
10
+ // 要的是『如果下载一个的时候,就停止另一个相同的安装』」。
11
+ //
12
+ // ── 本模块只做一件事:包的**互斥**(粒度 = 包,不是全局)────────────────────────────
13
+ // · 同一个 `profileDir + 包名` 同一时刻只允许**一个**安装作业;第二个**排队等待**(FIFO,先到先得)。
14
+ // · 不同包**照旧并行** —— 用户明确要「支持同时下载多个」,把闸门做成全局串行是错的。
15
+ // · 等待有上限(默认真机作业预算 10 分钟):到点如实拒绝(`INSTALL_LOCK_TIMEOUT`),绝不放行去动目录。
16
+ // · **可重入**:同一个异步上下文里已经持有该包的锁时直接放行(否则"安装收口里再调一次 pnpm"这种
17
+ // 嵌套会自己把自己锁死;用 AsyncLocalStorage 认上下文,不靠猜)。
18
+ //
19
+ // ── 为什么队列实现要复用 enrich-throttle.js#makeLimiter ────────────────────────────
20
+ // 仓库里已经有一份经过真机验证的 FIFO 计数闸门(`makeLimiter(max)`:绝不丢任务、先到先得、能报
21
+ // active/queued/peak)。用户的原话是「先搜,能复用就复用,不要另造一套」——所以这里**只**复用它的队列,
22
+ // 不重写调度。本模块新增的只有三样它没有的东西:
23
+ // ① 以「包」为键(每个键一个 `max=1` 的 limiter ⇒ 天然就是该包的串行队列);
24
+ // ② **可取消的排队票**:等待超时后把票标记作废,轮到它时**不执行**(`makeLimiter` 本身不支持取消,
25
+ // 但我们不需要改它 —— 传入的 fn 自己看票);
26
+ // ③ **可重入**(同上,AsyncLocalStorage)。
27
+ //
28
+ // ── 语义边界(别越线)────────────────────────────────────────────────────────────
29
+ // · 本模块**不安装任何东西**,也不判断安装成败:它只保证"同一时刻只有一个人在动同一个目录"。
30
+ // · 拿不到锁 ⇒ 抛错(由调用方如实报成失败);**绝不**降级成"不拿锁直接装"。
31
+ // · 空包名 / 空 profileDir ⇒ 直接放行(既有调用路径不传这些参数时行为一个字都不变)。
32
+ import { AsyncLocalStorage } from 'node:async_hooks'
33
+ import { resolve } from 'node:path'
34
+ import { makeLimiter } from './enrich-throttle.js'
35
+
36
+ /** 排队等待上限(默认 10 分钟):到点就如实拒绝,绝不无限等。
37
+ * 取值理由:单次作业预算 8 分钟(domain/install-job.js#jobBudgetMs),最快的一轮 pnpm 失败约 90s;
38
+ * 10 分钟足以覆盖"前一个作业跑完一轮"的绝大多数情形,又不会让第二个作业挂到用户以为死了。 */
39
+ const INSTALL_LOCK_WAIT_MS = 10 * 60 * 1000
40
+ /** 拒绝时的错误码(调用方据此产出如实文案;不要靠匹配中文)。 */
41
+ const INSTALL_LOCK_TIMEOUT = 'INSTALL_LOCK_TIMEOUT'
42
+
43
+ /** 锁键:`<规范化的 profileDir>::<包名>`。Windows 路径不区分大小写 ⇒ 统一小写,否则同一个目录会有两把锁。 */
44
+ function installLockKey(profileDir, packageName) {
45
+ const name = typeof packageName === 'string' ? packageName.trim() : ''
46
+ if (name === '') return ''
47
+ const dir = typeof profileDir === 'string' && profileDir.trim() !== '' ? profileDir.trim() : '<unknown-profile>'
48
+ let normalized = ''
49
+ try { normalized = resolve(dir) } catch { normalized = dir }
50
+ if (process.platform === 'win32') normalized = normalized.toLowerCase()
51
+ return `${normalized}::${name.toLowerCase()}`
52
+ }
53
+
54
+ // ── 模块级状态(一个进程一份;`__resetInstallLocks()` 供单测还原)─────────────────────────
55
+ const states = new Map()
56
+ const heldStore = new AsyncLocalStorage()
57
+
58
+ function lockStateFor(key) {
59
+ let st = states.get(key)
60
+ if (st === undefined) {
61
+ st = { key, limiter: makeLimiter(1), holder: null, acquired: 0, waits: 0, timeouts: 0, peakQueued: 0, lastWaitMs: 0, seq: 0 }
62
+ states.set(key, st)
63
+ }
64
+ return st
65
+ }
66
+
67
+ /** 当前是否有人在装这个包(**不拿锁**,只读状态;给"要不要提示用户在排队"用)。 */
68
+ function installLockHolder(profileDir, packageName) {
69
+ const key = installLockKey(profileDir, packageName)
70
+ if (key === '') return null
71
+ const st = states.get(key)
72
+ if (st === undefined || st.holder === null) return null
73
+ const s = st.limiter.stats()
74
+ return { ...st.holder, active: s.active, queued: s.queued }
75
+ }
76
+
77
+ /** 闸门现状(诊断 / 单测断言"不同包确实并行、同一个包确实串行"用)。 */
78
+ function installLockStats() {
79
+ const items = []
80
+ for (const st of states.values()) {
81
+ const s = st.limiter.stats()
82
+ items.push({ key: st.key, holder: st.holder?.packageName ?? null, holderSince: st.holder?.since ?? null, active: s.active, queued: s.queued, peak: s.max, acquired: st.acquired, waits: st.waits, timeouts: st.timeouts, lastWaitMs: st.lastWaitMs, peakQueued: st.peakQueued })
83
+ }
84
+ return { locks: items.length, items }
85
+ }
86
+
87
+ /** 清空全部在途记录(单测 & 诊断用;已在跑的作业不会被取消 —— 它们退出时也不会再写回这张表)。 */
88
+ function __resetInstallLocks() {
89
+ states.clear()
90
+ }
91
+
92
+ /** 超时拒绝的错误对象(文案要能直接展示给用户:谁在装、等了多久、下一步怎么做)。 */
93
+ function installLockTimeoutError({ packageName, waitedMs, holder = null }) {
94
+ const seconds = Math.max(1, Math.round(waitedMs / 1000))
95
+ const who = typeof holder?.packageName === 'string'
96
+ ? `另一个作业(${holder.jobId ?? '未命名作业'})正在安装这个包,已进行 ${Math.max(1, Math.round((Date.now() - Number(holder.since ?? Date.now())) / 1000))}s`
97
+ : '另一个作业正在安装这个包'
98
+ const error = new Error(`这个包正在安装中:${who}。本次已排队等待 ${seconds}s 仍未轮到,已放弃(没有动任何文件)——请等它结束后重试。`)
99
+ error.code = INSTALL_LOCK_TIMEOUT
100
+ error.packageName = packageName
101
+ error.waitedMs = waitedMs
102
+ error.holder = holder
103
+ return error
104
+ }
105
+
106
+ /**
107
+ * 过闸执行一次"动 `node_modules/<包名>`"的动作(**唯一入口**)。
108
+ * @param {object} spec
109
+ * @param {string} [spec.profileDir] profile 目录(与另一个作业同一个 profile 才会互斥)
110
+ * @param {string} [spec.packageName] 包名(锁的粒度就是它;空字符串 ⇒ 不过闸、直接执行)
111
+ * @param {number} [spec.waitMs] 排队上限(默认 INSTALL_LOCK_WAIT_MS;传 0 = 有人占着就立刻拒绝)
112
+ * @param {object} [spec.job] 作业(只用于把"我在排队 / 我等了多久"写回去,便于面板显示)
113
+ * @param {(info:{queued:boolean,waitedMs:number,key:string})=>void} [spec.onWait] 排队回调(写日志用)
114
+ * @param {() => Promise<any>} fn 真正要做的活
115
+ * @returns {Promise<any>} fn 的返回值;拿不到锁 ⇒ 抛 INSTALL_LOCK_TIMEOUT
116
+ */
117
+ async function withInstallLock(spec, fn, deps = {}) {
118
+ const packageName = typeof spec?.packageName === 'string' ? spec.packageName.trim() : ''
119
+ const key = installLockKey(spec?.profileDir, packageName)
120
+ if (key === '' || typeof fn !== 'function') return await fn()
121
+ // 可重入:同一个异步上下文里已经持有这个包 ⇒ 直接执行(防"安装收口里再调一次"把自己锁死)
122
+ const held = heldStore.getStore()
123
+ if (held !== undefined && held !== null && held.has(key) === true) return await fn({ queued: false, waitedMs: 0, key, reentrant: true })
124
+ const st = lockStateFor(key)
125
+ const now = typeof deps.now === 'function' ? deps.now : Date.now
126
+ const waitMs = Number.isFinite(spec?.waitMs) && spec.waitMs >= 0 ? spec.waitMs : INSTALL_LOCK_WAIT_MS
127
+ const startedAt = now()
128
+ // 「前面还有人」的判据要有两条:`holder` 是人可读的当前持有者,`limiter.active` 是队列的**权威**计数
129
+ // (同一个 tick 里两个调用同时进来时 holder 还是 null,只有 limiter 已经把它算进去了)。
130
+ const queued = st.holder !== null || st.limiter.stats().active > 0
131
+ const ticket = { cancelled: false }
132
+ if (queued) {
133
+ st.waits += 1
134
+ const depth = st.limiter.stats().queued + 1
135
+ if (depth > st.peakQueued) st.peakQueued = depth
136
+ const holder = st.holder === null ? null : { ...st.holder }
137
+ if (spec?.job !== null && spec?.job !== undefined && typeof spec.job === 'object') {
138
+ // 只增字段:面板/诊断据此显示"在排队等谁",不改任何既有作业字段的语义
139
+ spec.job.installLock = { key, packageName, queued: true, since: startedAt, waitedFor: holder?.packageName ?? null, holderJobId: holder?.jobId ?? null, waitMs }
140
+ }
141
+ if (typeof spec?.onWait === 'function') {
142
+ try { spec.onWait({ queued: true, waitedMs: 0, key, holder }) } catch {}
143
+ }
144
+ }
145
+ // 排队(max=1 的 FIFO;票作废后轮到它时**不执行**,只把队列让给下一位)
146
+ // `queueTimer` 在**拿到锁的那一刻**就清掉 —— 排队上限只约束"排队",绝不能把**已经轮到自己、
147
+ // 正在装**的那一次作业从后面掐断(真实形状:等 9 分钟才轮到,再装 8 分钟,旧写法会在第 10 分钟误杀)。
148
+ let queueTimer = null
149
+ const task = st.limiter.run(async () => {
150
+ if (ticket.cancelled === true) return { skipped: true }
151
+ if (queueTimer !== null) { clearTimeout(queueTimer); queueTimer = null }
152
+ const waitedMs = now() - startedAt
153
+ st.holder = { key, packageName, since: now(), jobId: spec?.job?.id ?? null, serial: (st.seq += 1) }
154
+ st.acquired += 1
155
+ st.lastWaitMs = waitedMs
156
+ if (spec?.job !== null && spec?.job !== undefined && typeof spec.job === 'object') {
157
+ spec.job.installLock = { key, packageName, queued: queued, waitedMs, acquiredAt: now() }
158
+ }
159
+ try {
160
+ return await heldStore.run(new Set([...(heldStore.getStore() ?? []), key]), () => fn({ queued, waitedMs, key, reentrant: false }))
161
+ } finally {
162
+ st.holder = null
163
+ }
164
+ })
165
+ // `waitMs === 0` = 明确拒绝策略(有人占着就立刻失败,不排队);否则立刻放行或排队
166
+ if (waitMs === 0) {
167
+ if (queued === true) {
168
+ st.timeouts += 1
169
+ ticket.cancelled = true
170
+ void task.catch(() => {})
171
+ throw installLockTimeoutError({ packageName, waitedMs: 0, holder: st.holder === null ? null : { ...st.holder } })
172
+ }
173
+ return await task
174
+ }
175
+ if (queued !== true) return await task
176
+ // 有上限地等:到点把票作废并如实拒绝(**不会**降级成"不拿锁直接装")
177
+ const guard = new Promise((resolveGuard, rejectGuard) => {
178
+ queueTimer = setTimeout(() => {
179
+ queueTimer = null
180
+ ticket.cancelled = true
181
+ const waitedMs = now() - startedAt
182
+ st.timeouts += 1
183
+ rejectGuard(installLockTimeoutError({ packageName, waitedMs, holder: st.holder === null ? null : { ...st.holder } }))
184
+ }, waitMs)
185
+ })
186
+ try {
187
+ return await Promise.race([task, guard])
188
+ } finally {
189
+ if (queueTimer !== null) { clearTimeout(queueTimer); queueTimer = null }
190
+ // 票作废后那个 limiter 任务仍会用 { skipped: true } 落地 —— 这里接住它,别留下未处理的 rejection
191
+ if (ticket.cancelled === true) void task.catch(() => {})
192
+ }
193
+ }
194
+
195
+ /** 失败文案(作业 error / 面板提示共用一份;非锁超时返回 null):拿不到锁时**如实失败**,绝不报成功。 */
196
+ function installLockFailureText(job) {
197
+ const lock = job !== null && job !== undefined && typeof job === 'object' ? job.installLock : null
198
+ if (lock === null || lock === undefined) return null
199
+ if (lock.timeout !== true) return null
200
+ return lock.reason ?? `这个包正在安装中(${lock.packageName ?? '未知包'}),本次排队等待超时,已放弃`
201
+ }
202
+
203
+ /** `withInstallLock` 的"拿不到锁就返回一份**如实**的结果"版本(只吞 `INSTALL_LOCK_TIMEOUT`,其余照抛)。
204
+ * `onTimeout(error)` 的返回值原样交给调用方 —— 调用方据此产出"没装成 + 原因",绝不假装成功。 */
205
+ async function withInstallLockOr(spec, fn, onTimeout, deps = {}) {
206
+ try {
207
+ return await withInstallLock(spec, fn, deps)
208
+ } catch (error) {
209
+ if (error?.code !== INSTALL_LOCK_TIMEOUT || typeof onTimeout !== 'function') throw error
210
+ return await onTimeout(error)
211
+ }
212
+ }
213
+
214
+ export {
215
+ INSTALL_LOCK_WAIT_MS,
216
+ INSTALL_LOCK_TIMEOUT,
217
+ installLockKey,
218
+ installLockHolder,
219
+ installLockStats,
220
+ installLockTimeoutError,
221
+ installLockFailureText,
222
+ withInstallLock,
223
+ withInstallLockOr,
224
+ __resetInstallLocks,
225
+ }