@noob-stupid/dsh-plugin-console 0.5.100 → 0.5.102

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,8 +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'
18
+ // 自更新兜底(2026-11-03 加法,见 domain/self-update-guard.js 顶部事故记录):只在"装的包名 === 本插件自己"时接管
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'
17
22
 
18
23
  /** release 通道的候选预算(issue #3):release 通道现在按包名反查发布仓库,**每个候选**至少一次
19
24
  * `releases?per_page=10` 调用(第一梯队仓库没命中还要多试几个候选仓库),而候选可能有十几个
@@ -21,6 +26,84 @@ import { tryPresetSourceChannel } from './preset-source.js'
21
26
  * 只给前 N 个候选扫 release;排在后面的候选仍照走 curl/并行竞速(按包名施工,代价小)。 */
22
27
  const RELEASE_CHANNEL_BUDGET = 3
23
28
 
29
+ /**
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 字段 + 通道调用顺序不变"钉住)。
39
+ *
40
+ * 为什么自更新兜底包在整条序列外面,而不是包在某一次 pnpm 调用里面:真机事故(宿主读着插件自己的文件 ⇒ EPERM、
41
+ * Windows 上 pnpm 先删旧目录再解包、删到一半被打断)里,**残缺态是整条序列结束时才看得见的**,
42
+ * 而"哪条通道声称装上了"完全不可信(那一次 curl/pnpm 都回了成功)。所以:
43
+ * ① 进来先备份(复制,不打扰正在运行的目录);
44
+ * ② 出去无论成败都校验一遍(try/catch 之后仍然跑 —— 这就是"pnpm 报失败也要校验");
45
+ * ③ 校验不过 ⇒ 把这次尝试**改判成没装上**(installedName=null + 如实错误),
46
+ * 由 install-job.js 的自更新分支收口成可见失败(绝不报"更新成功")。
47
+ */
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) {
75
+ const inner = () => tryCandidateChannelsInner(args)
76
+ const guardDeps = args?.selfUpdateDeps ?? {}
77
+ const session = beginSelfUpdateGuard({
78
+ packageName: args?.name, profileDir: args?.profileDir,
79
+ targetVersion: targetVersionOf(args?.job, args?.name), deps: guardDeps,
80
+ })
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
+ }
93
+ let outcome = null
94
+ let thrown = null
95
+ try {
96
+ outcome = await inner()
97
+ } catch (error) {
98
+ thrown = error
99
+ }
100
+ const selfUpdate = settleSelfUpdateGuard(session, { targetVersion: targetVersionOf(args?.job, args?.name), deps: guardDeps })
101
+ if (args?.job !== null && args?.job !== undefined && typeof args.job === 'object') args.job.selfUpdate = selfUpdate
102
+ if (thrown !== null) throw thrown
103
+ if (selfUpdate !== null && selfUpdate.verified === true) return outcome
104
+ return { installedName: null, lastError: new Error(selfUpdateFailureText(selfUpdate) ?? '自更新校验未通过(原因未知)') }
105
+ }
106
+
24
107
  /** 单个候选包的通道尝试序列(通道实现由 ch 注入;job.curlNote 等展示字段在此更新)。
25
108
  * 返回 { installedName, lastError }。三条守卫语义及理由(issue #3 要求逐条写清):
26
109
  *
@@ -37,7 +120,7 @@ const RELEASE_CHANNEL_BUDGET = 3
37
120
  * ③ git 通道:**保留 repoChannelAllowed**(它只 clone `job.repo`,候选是子包时 clone 根仓库装不出子包,
38
121
  * 属无意义尝试)**并保留 !expanded**(同一作业里对同一个 job.repo 反复 clone 纯属浪费时间,
39
122
  * 级联顺序也不该被破坏)。 */
40
- async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false, deadline = null, expand = null, sourceDeps = {} }) {
123
+ async function tryCandidateChannelsInner({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false, deadline = null, expand = null, sourceDeps = {} }) {
41
124
  const installSpec = (typeof job?.version === 'string' && job.version !== '' && name === job.packageName)
42
125
  ? `${name}@${job.version}`
43
126
  : name // ★ 0.5.81:规格与身份分离 —— name 仍是身份;点名包+指定版本时规格才带 @version
@@ -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,
@@ -16,6 +16,11 @@ import { RELEASE_CHANNEL_BUDGET, tryCandidateChannels } from './install-channels
16
16
  import { versionMissOf, versionMissText } from './install-version.js'
17
17
  import { settlePresetOutcome } from './preset-in-package.js'
18
18
  import { verifyInstalledEntry } from './install-verify.js'
19
+ // 自更新兜底(2026-11-03 加法,见 domain/self-update-guard.js 顶部):「自更新没落地」的那句话由它产出,
20
+ // 与通道层的 selfUpdate 报告同一份文案,别在作业里另写一套。
21
+ import { selfUpdateFailureText } from './self-update-guard.js'
22
+ // 通用超时/中断恢复(2026-11-04 加法,见 domain/install-recovery.js 顶部):「安装把目录弄坏了」的那句话由它产出
23
+ import { installRecoveryFailureText } from './install-recovery.js'
19
24
  import { applyNpmNameHint, composeProbeFailureText, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubdirPlugins, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
20
25
  import { decideNoRootManifest } from './subdir-detect.js'
21
26
  import { runSubdirInstallJob } from './subdir-install.js'
@@ -274,6 +279,9 @@ async function runInstallJob(job, ports, deps = {}) {
274
279
  const attempt = await tryCandidateChannels({
275
280
  job, ch, name, profileDir, registries, repoChannelAllowed, budget,
276
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,
277
285
  expand: (expanded || job.npmNameHint !== undefined) ? null : expandSubpackages,
278
286
  })
279
287
  installedName = attempt.installedName
@@ -283,6 +291,20 @@ async function runInstallJob(job, ports, deps = {}) {
283
291
  // 预设收口(D-③ 源码装配成功 / D-⑥ 落地物里带预设):能按预设收口就直接 return —— 见 preset-in-package.js
284
292
  // (这两条路都**没有 node_modules 插件**,写补丁行/声明依赖会让补丁行指向无入口模块 → 启动崩溃)。
285
293
  if (await settlePresetOutcome({ job, installedName, profileDir, repo: job.repo, deps: sourceDeps }) === true) return
294
+ // ★ 2026-11-03 加法(真机两次事故:控制台自更新被打断 ⇒ 目录只剩 scripts/ 与 SECURITY.md):
295
+ // **自更新校验不过 ⇒ 如实失败,且不清场、不进 AI 兜底。** 那一刻盘上要么是"刚从备份恢复好的旧版"、
296
+ // 要么是"已改名挪开的残缺目录"——再让模型去"修"只会把刚恢复好的东西再动一遍;而清场
297
+ // (cleanupAttemptedCandidates)会把恢复回来的那一份也删掉,那才是真损失。
298
+ // 位置刻意放在版本闸分支**之前**:自更新的缺口(被打断/半棵树/没落地)比"镜像 latest 陈旧"更具体,
299
+ // 先说清是哪一种;失败原因、残缺目录位置与可直接复制运行的命令,全在 selfUpdate 里。
300
+ // 非自更新作业永远进不到这里(job.selfUpdate 只由 domain/self-update-guard.js 写入)。
301
+ if (installedName === null && job.selfUpdate != null && job.selfUpdate.verified !== true) {
302
+ job.status = 'failed'
303
+ job.stage = 'self-update-check'
304
+ job.error = selfUpdateFailureText(job.selfUpdate)
305
+ job.lastError = job.error
306
+ return
307
+ }
286
308
  // ★ 0.5.87(真机 2026-10-18 的根因收口):**点名了版本却没装上**⇒ 如实失败,且**不清场**。
287
309
  // 为什么不清场:下面的失败路径会调 cleanupAttemptedCandidates,它会 rmSync 掉
288
310
  // `node_modules/<包名>` —— 而那一刻盘上放着的是用户**现在还能用**的旧版本(本例 0.1.6-alpha.1)。
@@ -306,6 +328,18 @@ async function runInstallJob(job, ports, deps = {}) {
306
328
  return
307
329
  }
308
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
+ }
309
343
  if (job.diagnosis == null) job.diagnosis = classifyInstallFailure(lastError?.message) // 分类提示兜底(加法)
310
344
  // 本地 AI 兜底会调用模型 API、产生费用:挂起等待用户明确同意后再执行
311
345
  job.stage = 'ai-consent'
@@ -341,15 +375,23 @@ async function runInstallJob(job, ports, deps = {}) {
341
375
  job.status = 'failed'
342
376
  // 失败/取消时清场并如实汇报(见 cleanupAttemptedCandidates 注释):
343
377
  // 不能让用户面对"面板说失败、磁盘上却留了半个包和 _tmp_ 残留"的糊涂账。
344
- 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 })
345
384
  job.leftovers = leftovers
346
385
  // ★ 失败路径 B 的文案(0.5.41 加法):**只有如实失败 + 原因**。取样/下载本身失败时磁盘上
347
386
  // 什么都没有 ⇒ 没有任何东西可挂 ⇒ 面板**绝不**出现"回落成功 / 已安装 / 已启用"之类说法。
348
387
  // 这里把最后一次的真实失败原因接在既有文案之后(不改写、不掩盖),并保留既有诊断分类(可重试动作
349
388
  // 由面板的「重试」按钮与 diagnosis.hint 承担)。
350
389
  const reason = typeof lastError?.message === 'string' && lastError.message !== '' ? lastError.message : null
390
+ // ★ 2026-11-04 加法:恢复/校验的结论单独接一句(绝不与"装上了"混在一起)
391
+ const recoveryNote = installRecoveryFailureText(job.installRecovery)
351
392
  job.error = aiConsentFailureText(decision, leftovers, aiConsentTimeoutMs)
352
393
  + (reason === null ? '' : `;最后一次失败原因:${reason}`)
394
+ + (recoveryNote === null ? '' : `;${recoveryNote}`)
353
395
  }
354
396
  return
355
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
+ }