@1e0zj/dsh-plugin-mall 0.4.1 → 0.4.2

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/src/installer.js CHANGED
@@ -1356,8 +1356,13 @@ export function assertSafeSpec(spec) {
1356
1356
  * Try to provision pnpm once via `corepack enable pnpm` (corepack ships with
1357
1357
  * Node). Output lands in the caller's job log; returns whether a retry of the
1358
1358
  * pnpm spawn is worth attempting.
1359
+ *
1360
+ * `onProc` hands the child to the caller so job_kill can reach it. Without it
1361
+ * this was a hole in cancellation: `current` still pointed at the pnpm spawn
1362
+ * that had just failed with ENOENT, so cancel() killed nothing while corepack
1363
+ * ran on, and the caller then started a full install anyway.
1359
1364
  */
1360
- async function enablePnpmViaCorepack(push) {
1365
+ async function enablePnpmViaCorepack(push, onProc) {
1361
1366
  push("\n[dsh-plugin-mall] pnpm not found on PATH — trying `corepack enable pnpm` once\n");
1362
1367
  return await new Promise((resolve) => {
1363
1368
  let proc;
@@ -1372,6 +1377,9 @@ async function enablePnpmViaCorepack(push) {
1372
1377
  resolve(false);
1373
1378
  return;
1374
1379
  }
1380
+ // shell:true on Windows means the real corepack is a grandchild of cmd.exe,
1381
+ // so the same tree-kill rule as pnpm applies.
1382
+ onProc?.({ proc, treeKill: process.platform === "win32" });
1375
1383
  proc.on("error", () => resolve(false));
1376
1384
  proc.stdout?.on("data", (data) => push(data.toString()));
1377
1385
  proc.stderr?.on("data", (data) => push(data.toString()));
@@ -1490,6 +1498,49 @@ function cancelSpawned(current) {
1490
1498
  }
1491
1499
  }
1492
1500
 
1501
+ /**
1502
+ * Did this pnpm run end because WE cancelled it?
1503
+ *
1504
+ * `exitCode === null` was the sole test, and it is a POSIX-only tell: it holds
1505
+ * when Node itself signals a directly-spawned child, which is what every
1506
+ * fixture here does (FakeProc emits close(null, "SIGTERM")). The real Windows
1507
+ * path never produces it. pnpm is a .cmd shim, so it must be spawned through a
1508
+ * shell wrapper, and cancelling means killProcessTree → `taskkill /T /F`; the
1509
+ * wrapper is then TERMINATED rather than signalled, and Node reports
1510
+ * `close(1, null)` — measured, not assumed. So a user pressing job_kill during
1511
+ * `pnpm add` got:
1512
+ *
1513
+ * failed, pnpm add <spec> failed (exit code 1). See job output.
1514
+ *
1515
+ * The model reading that concludes the plugin cannot be installed and starts
1516
+ * debugging a problem that does not exist — registry, network, the candidate
1517
+ * itself — when all that happened is the user cancelled. Rollback ran either
1518
+ * way (the outer handler rolls back anything that is neither `completed` nor
1519
+ * an approval pause), so this was purely a misreported ending — the same class
1520
+ * of bug as a cancelled preflight surfacing as a blocked verdict.
1521
+ *
1522
+ * Our own intent is the reliable signal, so it is checked first; the exitCode
1523
+ * test stays for a kill that arrives from outside this process.
1524
+ */
1525
+ function endedByCancel(outcome, cancelRequested) {
1526
+ return cancelRequested === true || outcome.exitCode === null;
1527
+ }
1528
+
1529
+ /**
1530
+ * How a cancelled run describes itself — exit codes are noise once cancelled.
1531
+ *
1532
+ * It says what happened to the PROCESS and stops there. What happened to the
1533
+ * profile is the rollback's verdict to give, and the rollback has not run yet
1534
+ * when this is called: promising "the profile was restored" here would print
1535
+ * that even when the rollback later fails, which is the one moment the user
1536
+ * must be told to go look. rollbackRemove() already models this correctly with
1537
+ * three distinct endings; the callers below append theirs the same way.
1538
+ */
1539
+ function cancelDetail(outcome, cancelRequested) {
1540
+ if (cancelRequested === true) return "cancelled — pnpm was terminated";
1541
+ return outcome.signal ? `signal: ${outcome.signal}` : "killed before exit";
1542
+ }
1543
+
1493
1544
  /** Mirrors guard.js pendingPath(): <home>/guard/pending-<profile>.json. */
1494
1545
  function pendingMarkerPath(profileDir) {
1495
1546
  return join(dirname(dirname(profileDir)), "guard", `pending-${basename(profileDir)}.json`);
@@ -1587,17 +1638,17 @@ function liveAddEnv(base = process.env) {
1587
1638
  };
1588
1639
  }
1589
1640
 
1590
- export function runInstall({ profile, spec, allowBuildScripts, approvedProof, preflight, _profileDir, _spawn, _describe, _restoreWorkspace }) {
1641
+ export function runInstall({ profile, spec, allowBuildScripts, approvedProof, preflight, _profileDir, _spawn, _describe, _restoreWorkspace, _corepack }) {
1591
1642
  // Serialized with every other add/remove targeting the same profile (see
1592
1643
  // serializedProducer). Underscored arguments are self-test seams; production
1593
1644
  // callers never pass them. `_restoreWorkspace` exists specifically so the
1594
1645
  // fail-closed restoration path can be attacked without relying on flaky OS
1595
1646
  // permission tricks.
1596
1647
  return serializedProducer(_profileDir ?? profileLockKey(profile), () =>
1597
- runInstallInner({ profile, spec, allowBuildScripts, approvedProof, preflight, _profileDir, _spawn, _describe, _restoreWorkspace }));
1648
+ runInstallInner({ profile, spec, allowBuildScripts, approvedProof, preflight, _profileDir, _spawn, _describe, _restoreWorkspace, _corepack }));
1598
1649
  }
1599
1650
 
1600
- function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, preflight, _profileDir, _spawn, _describe, _restoreWorkspace }) {
1651
+ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, preflight, _profileDir, _spawn, _describe, _restoreWorkspace, _corepack = enablePnpmViaCorepack }) {
1601
1652
  const profileDir = _profileDir ?? ensureProfile(profile);
1602
1653
  try {
1603
1654
  assertNoManifestBuildBypass(profileDir);
@@ -1718,6 +1769,7 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1718
1769
  }
1719
1770
 
1720
1771
  let current = undefined;
1772
+ let cancelRequested = false; // see endedByCancel: exit codes cannot tell us this on Windows
1721
1773
  let pnpmSelfHealed = false;
1722
1774
  const plan = _spawn === undefined ? pnpmSpawnPlan() : { command: "pnpm", shell: false, treeKill: false };
1723
1775
  const spawnImpl = _spawn ?? spawn;
@@ -1794,7 +1846,13 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1794
1846
  if (outcome.spawnError !== undefined) {
1795
1847
  if (outcome.spawnError.code === "ENOENT" && !pnpmSelfHealed) {
1796
1848
  pnpmSelfHealed = true;
1797
- const healed = await enablePnpmViaCorepack(push);
1849
+ const healed = await _corepack(push, (child) => { current = child; });
1850
+ // 取消要在 retry 之前判。corepack 可能已经被杀、也可能刚好装完,
1851
+ // 无论哪种,用户按过 kill 之后就绝不能再去动 profile。
1852
+ if (cancelRequested) {
1853
+ restoreOriginalWorkspace();
1854
+ return { status: "killed", detail: cancelDetail(outcome, cancelRequested) };
1855
+ }
1798
1856
  if (healed) {
1799
1857
  const retry = spawnAdd();
1800
1858
  current = retry;
@@ -1809,9 +1867,9 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1809
1867
  : `could not start pnpm: ${outcome.spawnError.message}`;
1810
1868
  return { status: "failed", detail: hint };
1811
1869
  }
1812
- if (outcome.exitCode === null) {
1870
+ if (endedByCancel(outcome, cancelRequested)) {
1813
1871
  restoreOriginalWorkspace();
1814
- return { status: "killed", detail: outcome.signal ? `signal: ${outcome.signal}` : "killed before exit" };
1872
+ return { status: "killed", detail: cancelDetail(outcome, cancelRequested) };
1815
1873
  }
1816
1874
  const log = collected.join("");
1817
1875
  const ignored = parseIgnoredBuilds(log);
@@ -1898,8 +1956,8 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1898
1956
  if (retryOutcome.spawnError !== undefined) {
1899
1957
  return { status: "failed", detail: `retry could not start pnpm: ${retryOutcome.spawnError.message}` };
1900
1958
  }
1901
- if (retryOutcome.exitCode === null) {
1902
- return { status: "killed", detail: retryOutcome.signal ? `signal: ${retryOutcome.signal}` : "killed before exit" };
1959
+ if (endedByCancel(retryOutcome, cancelRequested)) {
1960
+ return { status: "killed", detail: cancelDetail(retryOutcome, cancelRequested) };
1903
1961
  }
1904
1962
  if (retryOutcome.exitCode === 0) {
1905
1963
  return tryFinalize();
@@ -1966,11 +2024,37 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1966
2024
  }
1967
2025
  push("\n[dsh-plugin-mall] install paused for build-script approval — the candidate stays installed with its scripts blocked; approve in the UI to finish, or restart dsh / run `dsh-plugin-guard guard recover` to roll back\n");
1968
2026
  } else {
2027
+ // 回滚的结局必须进 detail,不能只进日志流。detail 是模型和面板唯一
2028
+ // 一定会读到的东西;把「没能还原」只写进日志,等于让一个状态未知的
2029
+ // profile 以一句 killed/failed 悄悄收场。
2030
+ //
2031
+ // 三种结局分开说,和 rollbackRemove 同一套口径:
2032
+ // 还原了 / 没有还原目标(marker 不见了)/ 回滚自己也失败了。
2033
+ // 后两种一律 failed —— 取消如果没还原成,那就不是一次干净的取消。
1969
2034
  try {
1970
- rollbackPendingSnapshot(profileDir);
1971
- push("\n[dsh-plugin-mall] install did not complete — restored profile files to their pre-install state and cleared the pending marker\n");
2035
+ const rolled = rollbackPendingSnapshot(profileDir);
2036
+ if (rolled === undefined) {
2037
+ // 返回 undefined 不抛:marker 不见了,没有还原目标。此前这里
2038
+ // 照样打印「已还原」,是一句彻底的谎话。
2039
+ push("\n[dsh-plugin-mall] WARNING: no pending marker was found, so the profile could NOT be restored automatically\n");
2040
+ result = {
2041
+ ...result,
2042
+ status: "failed",
2043
+ detail: `${result.detail ?? ""}; no pending marker was found, so the profile could NOT be restored automatically — check it before the next start (\`dsh-plugin-guard guard validate --profile ${profile}\`)`,
2044
+ };
2045
+ } else {
2046
+ push("\n[dsh-plugin-mall] install did not complete — restored profile files to their pre-install state and cleared the pending marker\n");
2047
+ if (result.status === "killed") {
2048
+ result = { ...result, detail: `${result.detail ?? ""}; the profile was restored to its pre-install state` };
2049
+ }
2050
+ }
1972
2051
  } catch (error) {
1973
2052
  push(`\n[dsh-plugin-mall] WARNING: could not roll back the pending snapshot: ${error.message}\n`);
2053
+ result = {
2054
+ ...result,
2055
+ status: "failed",
2056
+ detail: `${result.detail ?? ""}; rollback also failed and the pending marker was kept for recovery: ${error.message}. Check the profile before the next start (restart dsh, or run \`dsh-plugin-guard guard recover\`)`,
2057
+ };
1974
2058
  }
1975
2059
  }
1976
2060
  return result;
@@ -1978,6 +2062,7 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1978
2062
 
1979
2063
  return {
1980
2064
  cancel: () => {
2065
+ cancelRequested = true; // record intent BEFORE the kill — the exit code will not carry it
1981
2066
  cancelSpawned(current);
1982
2067
  },
1983
2068
  done,
@@ -2016,14 +2101,14 @@ function failedNow(detail, { staleOnRestart = false } = {}) {
2016
2101
  * `dsh.profile.bundles` (the removed dependency's bundle entry drops out) and
2017
2102
  * deletes the client loader row `ensureClientRow` had registered for it.
2018
2103
  */
2019
- export function runRemove({ profile, packageName, _profileDir, _spawn }) {
2104
+ export function runRemove({ profile, packageName, _profileDir, _spawn, _corepack }) {
2020
2105
  // Same per-profile queue as runInstall — a remove must never run
2021
2106
  // concurrently with an install (or another remove) in the same profile.
2022
2107
  return serializedProducer(_profileDir ?? profileLockKey(profile), () =>
2023
- runRemoveInner({ profile, packageName, _profileDir, _spawn }, false));
2108
+ runRemoveInner({ profile, packageName, _profileDir, _spawn, _corepack }, false));
2024
2109
  }
2025
2110
 
2026
- function runRemoveInner({ profile, packageName, _profileDir, _spawn }, selfHealed) {
2111
+ function runRemoveInner({ profile, packageName, _profileDir, _spawn, _corepack = enablePnpmViaCorepack }, selfHealed) {
2027
2112
  let profileDir;
2028
2113
  if (_profileDir !== undefined) {
2029
2114
  profileDir = _profileDir;
@@ -2060,6 +2145,7 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn }, selfHeale
2060
2145
  deltaQueue.push(text);
2061
2146
  };
2062
2147
  let current = undefined;
2148
+ let cancelRequested = false; // see endedByCancel: exit codes cannot tell us this on Windows
2063
2149
 
2064
2150
  // Snapshot + pending marker, exactly as an install does.
2065
2151
  //
@@ -2089,21 +2175,29 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn }, selfHeale
2089
2175
  return failedNow(`cannot register the remove pending marker for ${packageName}: ${error.message} — refusing to touch the profile`);
2090
2176
  }
2091
2177
 
2092
- /** Restore the pre-remove bytes and settle the marker; never throws. */
2178
+ /**
2179
+ * Restore the pre-remove bytes and settle the marker; never throws.
2180
+ *
2181
+ * `rolledBack` says whether the profile is actually back to its pre-remove
2182
+ * state. Only the caller that cancelled may upgrade this to `killed`, and
2183
+ * only when it is true: a cancel whose rollback did not happen is not a
2184
+ * clean stop, it is a profile in an unknown state, and it has to keep the
2185
+ * `failed` status so nothing downstream reads it as "nothing to see here".
2186
+ */
2093
2187
  const rollbackRemove = (reason) => {
2094
2188
  try {
2095
2189
  const rolled = rollbackPendingSnapshot(profileDir);
2096
2190
  if (rolled === undefined) {
2097
2191
  // marker 不见了(外部删除、或本轮压根没登记成功)——没有还原目标,
2098
2192
  // 就绝不能声称已还原。说实话比说好听重要:用户据此决定要不要手工检查。
2099
- return { status: "failed", detail: `${reason}; no pending marker was found, so the profile could NOT be restored automatically — check it before the next start (\`dsh-plugin-guard guard validate --profile ${profile}\`)` };
2193
+ return { status: "failed", rolledBack: false, detail: `${reason}; no pending marker was found, so the profile could NOT be restored automatically — check it before the next start (\`dsh-plugin-guard guard validate --profile ${profile}\`)` };
2100
2194
  }
2101
2195
  const rebuild = describeRollbackRebuild(rolled.rebuild);
2102
- return { status: "failed", detail: `${reason}; the profile was restored to its pre-remove state${rebuild === undefined ? "" : ` (node_modules rebuild — ${rebuild})`}` };
2196
+ return { status: "failed", rolledBack: true, detail: `${reason}; the profile was restored to its pre-remove state${rebuild === undefined ? "" : ` (node_modules rebuild — ${rebuild})`}` };
2103
2197
  } catch (rollbackError) {
2104
2198
  // 回滚失败时**保留 marker**:磁盘状态未知,交给启动恢复/`guard recover`,
2105
2199
  // 绝不能声称已还原。
2106
- return { status: "failed", detail: `${reason}; rollback also failed and the pending marker was kept for recovery: ${rollbackError.message}` };
2200
+ return { status: "failed", rolledBack: false, detail: `${reason}; rollback also failed and the pending marker was kept for recovery: ${rollbackError.message}` };
2107
2201
  }
2108
2202
  };
2109
2203
 
@@ -2134,7 +2228,13 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn }, selfHeale
2134
2228
  // producer——若走 runRemove 重新排队会死锁——且必须返回它的 done
2135
2229
  // outcome,返回 producer 本体会让 tracker 把成功任务记成 failed)。
2136
2230
  if (outcome.spawnError.code === "ENOENT" && selfHealed !== true) {
2137
- const healed = await enablePnpmViaCorepack(push);
2231
+ const healed = await _corepack(push, (child) => { current = child; });
2232
+ // 取消要在重试之前判,理由同 install 侧:用户按过 kill 之后就不能再
2233
+ // 去动 profile。这一轮 pnpm 从未启动,所以没有东西需要回滚。
2234
+ if (cancelRequested) {
2235
+ const rolled = rollbackRemove(cancelDetail(outcome, cancelRequested));
2236
+ return rolled.rolledBack === true ? { ...rolled, status: "killed" } : rolled;
2237
+ }
2138
2238
  // 重试前先把本轮的事务状态收掉——否则重试那一轮会被自己的 marker 挡住。
2139
2239
  // 收不掉就不许重试:递归只会撞上自己的 marker 并返回笼统的「有未了结
2140
2240
  // 事务」,把真正的回滚错因盖掉,而那正是用户需要看到的东西。
@@ -2144,7 +2244,7 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn }, selfHeale
2144
2244
  } catch (rollbackError) {
2145
2245
  return { status: "failed", detail: `pnpm was missing and corepack enabled it, but clearing this remove's pending marker failed, so the retry was not attempted: ${rollbackError.message}. The marker was kept for recovery (restart dsh, or run \`dsh-plugin-guard guard recover\`).` };
2146
2246
  }
2147
- return await runRemoveInner({ profile, packageName, _profileDir, _spawn }, true).done;
2247
+ return await runRemoveInner({ profile, packageName, _profileDir, _spawn, _corepack }, true).done;
2148
2248
  }
2149
2249
  }
2150
2250
  const hint = outcome.spawnError.code === "ENOENT"
@@ -2152,10 +2252,12 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn }, selfHeale
2152
2252
  : `could not start pnpm: ${outcome.spawnError.message}`;
2153
2253
  return rollbackRemove(hint);
2154
2254
  }
2155
- if (outcome.exitCode === null) {
2255
+ if (endedByCancel(outcome, cancelRequested)) {
2156
2256
  // 取消也要还原:pnpm 可能已经删掉了 node_modules 里的目录。
2157
- const killed = rollbackRemove(outcome.signal ? `signal: ${outcome.signal}` : "killed before exit");
2158
- return { ...killed, status: "killed" };
2257
+ const rolled = rollbackRemove(cancelDetail(outcome, cancelRequested));
2258
+ // 还原成功才算干净取消。没还原成功就维持 failed——磁盘状态未知、marker
2259
+ // 还在,报成 killed 会让上层(和模型)当作「什么都没发生」。
2260
+ return rolled.rolledBack === true ? { ...rolled, status: "killed" } : rolled;
2159
2261
  }
2160
2262
  if (outcome.exitCode !== 0) {
2161
2263
  return rollbackRemove(`pnpm remove ${packageName} failed (exit code ${outcome.exitCode}). See job output.`);
@@ -2192,6 +2294,7 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn }, selfHeale
2192
2294
 
2193
2295
  return {
2194
2296
  cancel: () => {
2297
+ cancelRequested = true; // record intent BEFORE the kill — the exit code will not carry it
2195
2298
  cancelSpawned(current);
2196
2299
  },
2197
2300
  done,
@@ -2410,16 +2513,32 @@ function runNeutralizeFixtures() {
2410
2513
  // profile, pnpm, or network is involved.
2411
2514
 
2412
2515
  /** Minimal fake ChildProcess: stdout/stderr emitters, kill(), manual finish. */
2516
+ /**
2517
+ * `killAs` picks which platform's cancellation this fake reproduces.
2518
+ *
2519
+ * It used to hard-code the POSIX one — close(null, "SIGTERM") — and that is
2520
+ * precisely why the "取消在途 install → killed" fixture stayed green while
2521
+ * the real Windows path reported `failed (exit code 1)`: the fake was written
2522
+ * from the code's assumption instead of from what the OS does. On Windows the
2523
+ * shell-wrapped pnpm is terminated by `taskkill /T /F`, not signalled, and
2524
+ * Node reports close(1, null). Both dialects are now pinned.
2525
+ */
2413
2526
  class FakeProc extends EventEmitter {
2414
- constructor() {
2527
+ constructor(killAs = "posix") {
2415
2528
  super();
2416
2529
  this.stdout = new EventEmitter();
2417
2530
  this.stderr = new EventEmitter();
2418
2531
  this.pid = 424242;
2419
2532
  this.signalCode = null;
2533
+ this.killAs = killAs;
2420
2534
  }
2421
2535
  kill() {
2422
2536
  queueMicrotask(() => {
2537
+ if (this.killAs === "win32") {
2538
+ // taskkill /T /F: terminated, never signalled — an ordinary nonzero exit.
2539
+ this.emit("close", 1, null);
2540
+ return;
2541
+ }
2423
2542
  this.signalCode = "SIGTERM";
2424
2543
  this.emit("close", null, "SIGTERM");
2425
2544
  });
@@ -2450,10 +2569,10 @@ function scriptedSpawn(steps) {
2450
2569
  }
2451
2570
 
2452
2571
  /** Spawn fake whose procs stay alive until the test finishes them. */
2453
- function blockingSpawn() {
2572
+ function blockingSpawn(killAs = "posix") {
2454
2573
  const procs = [];
2455
2574
  const spawnFn = () => {
2456
- const proc = new FakeProc();
2575
+ const proc = new FakeProc(killAs);
2457
2576
  procs.push(proc);
2458
2577
  return proc;
2459
2578
  };
@@ -3309,11 +3428,12 @@ async function runTransactionFixtures() {
3309
3428
 
3310
3429
  // 3b-6. 在途取消:必须等进程真正退出后才回滚,否则会与还在写盘的 pnpm 抢。
3311
3430
  // 安装路径早有这条,卸载路径此前没有。
3312
- {
3313
- const { profileDir, cleanup } = makeTempProfile("remove-cancel-inflight", { "pkg-g": "1.0.0" });
3431
+ // 两种终止方言都跑,理由同 install 侧的 4b。
3432
+ for (const killAs of ["posix", "win32"]) {
3433
+ const { profileDir, cleanup } = makeTempProfile(`remove-cancel-inflight-${killAs}`, { "pkg-g": "1.0.0" });
3314
3434
  try {
3315
3435
  materializeFakePackage(profileDir, "pkg-g", "1.0.0");
3316
- const { spawnFn, procs } = blockingSpawn();
3436
+ const { spawnFn, procs } = blockingSpawn(killAs);
3317
3437
  const producer = runRemove({ profile: "p", packageName: "pkg-g", _profileDir: profileDir, _spawn: spawnFn });
3318
3438
  await flush();
3319
3439
  // 与 install 的取消用例同规格:spawn 起来了、marker 已登记,取消之后
@@ -3324,13 +3444,57 @@ async function runTransactionFixtures() {
3324
3444
  producer.cancel();
3325
3445
  const outcome = await producer.done;
3326
3446
  check(
3327
- "取消在途 remove killed + 回滚收 marker,包仍在盘上",
3447
+ `取消在途 remove(${killAs} 终止方言)→ killed + 回滚收 marker,包仍在盘上`,
3328
3448
  spawnedAndMarked
3329
3449
  && outcome.status === "killed"
3330
3450
  && !existsSync(pendingMarkerPath(profileDir))
3331
3451
  && existsSync(join(profileDir, "node_modules", "pkg-g")),
3332
3452
  `spawnedAndMarked=${spawnedAndMarked} status=${outcome.status} marker=${existsSync(pendingMarkerPath(profileDir))} detail=${JSON.stringify(outcome.detail)}`,
3333
3453
  );
3454
+ check(
3455
+ `取消在途 remove(${killAs})→ detail 不谎称 pnpm 失败`,
3456
+ !/exit code/.test(outcome.detail ?? ""),
3457
+ `detail=${outcome.detail}`,
3458
+ );
3459
+ check(
3460
+ `取消在途 remove(${killAs})→ 回滚成功才说已还原`,
3461
+ /the profile was restored to its pre-remove state/.test(outcome.detail ?? ""),
3462
+ `detail=${outcome.detail}`,
3463
+ );
3464
+ } finally {
3465
+ cleanup();
3466
+ }
3467
+ }
3468
+
3469
+ // 3b-6b. 卸载侧的同一格:取消了但没有还原目标。`{...rolled, status:"killed"}`
3470
+ // 曾经无条件把 rollbackRemove 判定的 failed 覆盖成 killed,等于把它三种
3471
+ // 结局里的两种失败结论抹平成一句「已取消」。
3472
+ {
3473
+ const { profileDir, cleanup } = makeTempProfile("remove-cancel-rollback-fails", { "pkg-g": "1.0.0" });
3474
+ try {
3475
+ materializeFakePackage(profileDir, "pkg-g", "1.0.0");
3476
+ const { spawnFn } = blockingSpawn("win32");
3477
+ const producer = runRemove({ profile: "p", packageName: "pkg-g", _profileDir: profileDir, _spawn: spawnFn });
3478
+ await flush();
3479
+ rmSync(pendingMarkerPath(profileDir), { force: true }); // 抽掉还原目标
3480
+ producer.cancel();
3481
+ const outcome = await producer.done;
3482
+ const detail = outcome.detail ?? "";
3483
+ check(
3484
+ "取消 + 回滚失败(remove)→ 退回 failed,不报 killed",
3485
+ outcome.status === "failed",
3486
+ `status=${outcome.status} detail=${detail}`,
3487
+ );
3488
+ check(
3489
+ "取消 + 回滚失败(remove)→ 绝不声称已还原",
3490
+ !/was restored/.test(detail),
3491
+ `detail=${detail}`,
3492
+ );
3493
+ check(
3494
+ "取消 + 回滚失败(remove)→ 指名 marker 与排查手段",
3495
+ /marker/.test(detail) && /guard validate|guard recover|before the next start/.test(detail),
3496
+ `detail=${detail}`,
3497
+ );
3334
3498
  } finally {
3335
3499
  cleanup();
3336
3500
  }
@@ -3460,11 +3624,16 @@ async function runTransactionFixtures() {
3460
3624
 
3461
3625
  // 4b. 取消时序:cancel() → 进程 close → killed 结局 → 回滚收 marker,
3462
3626
  // 回滚严格发生在进程退出之后(done 链只在 'close' 后推进)。
3463
- {
3464
- const { profileDir, cleanup } = makeTempProfile("cancel-order");
3627
+ //
3628
+ // 两种平台方言都要跑。只跑 posix 的时候这条一直是绿的,而真实 Windows
3629
+ // 用户按 job_kill 收到的是 `failed (exit code 1)`——taskkill /T /F 是终止
3630
+ // 不是发信号,Node 报 close(1, null),`exitCode === null` 判据落空。fixture
3631
+ // 照着代码的假设写,就只能验证代码符合自己的假设。
3632
+ for (const killAs of ["posix", "win32"]) {
3633
+ const { profileDir, cleanup } = makeTempProfile(`cancel-order-${killAs}`);
3465
3634
  try {
3466
3635
  materializeFakePackage(profileDir, "pkg-a", "1.0.0");
3467
- const { spawnFn, procs } = blockingSpawn();
3636
+ const { spawnFn, procs } = blockingSpawn(killAs);
3468
3637
  const install = runInstall({ profile: "p", spec: "pkg-a", preflight: preflightStub("pkg-a"), _profileDir: profileDir, _spawn: spawnFn });
3469
3638
  await flush();
3470
3639
  const spawnedAndMarked = procs.length === 1 && existsSync(pendingMarkerPath(profileDir));
@@ -3472,12 +3641,118 @@ async function runTransactionFixtures() {
3472
3641
  const outcome = await install.done;
3473
3642
  const output = (() => { let text = ""; let chunk = install.readOutput(); while (chunk.length > 0) { text += chunk; chunk = install.readOutput(); } return text; })();
3474
3643
  check(
3475
- "取消在途 install killed + 回滚收 marker(在进程退出之后)",
3644
+ `取消在途 install(${killAs} 终止方言)→ killed + 回滚收 marker(在进程退出之后)`,
3476
3645
  spawnedAndMarked
3477
3646
  && outcome.status === "killed"
3478
3647
  && !existsSync(pendingMarkerPath(profileDir))
3479
3648
  && /restored profile files/.test(output),
3480
- `status=${outcome.status} marker=${existsSync(pendingMarkerPath(profileDir))}`,
3649
+ `status=${outcome.status} detail=${outcome.detail} marker=${existsSync(pendingMarkerPath(profileDir))}`,
3650
+ );
3651
+ // 取消的结局不许把 pnpm 的退出码当成失败原因报出去——模型读到
3652
+ // "failed (exit code 1)" 会去排查一个根本不存在的安装故障。
3653
+ check(
3654
+ `取消在途 install(${killAs})→ detail 不谎称 pnpm 失败`,
3655
+ !/exit code/.test(outcome.detail ?? ""),
3656
+ `detail=${outcome.detail}`,
3657
+ );
3658
+ check(
3659
+ `取消在途 install(${killAs})→ 回滚成功才说已还原`,
3660
+ /the profile was restored to its pre-install state/.test(outcome.detail ?? ""),
3661
+ `detail=${outcome.detail}`,
3662
+ );
3663
+ } finally {
3664
+ cleanup();
3665
+ }
3666
+ }
3667
+
3668
+ // 4b-2. 取消了,但回滚做不成。marker 被外部删掉(外部清理、磁盘故障、
3669
+ // 或本轮压根没登记上),rollbackPendingSnapshot 返回 undefined 而不抛。
3670
+ //
3671
+ // 这是最不能撒谎的一格:profile 停在 pnpm 动过一半的状态,没有还原目标,
3672
+ // 而用户看到的如果是「killed,已还原」,他就不会去查——正是他必须去查的
3673
+ // 那一次。所以结局必须退回 failed,并且指名 marker 和排查手段。
3674
+ {
3675
+ const { profileDir, cleanup } = makeTempProfile("cancel-rollback-fails");
3676
+ try {
3677
+ materializeFakePackage(profileDir, "pkg-a", "1.0.0");
3678
+ const { spawnFn } = blockingSpawn("win32");
3679
+ const install = runInstall({ profile: "p", spec: "pkg-a", preflight: preflightStub("pkg-a"), _profileDir: profileDir, _spawn: spawnFn });
3680
+ await flush();
3681
+ rmSync(pendingMarkerPath(profileDir), { force: true }); // 抽掉还原目标
3682
+ install.cancel();
3683
+ const outcome = await install.done;
3684
+ const detail = outcome.detail ?? "";
3685
+ check(
3686
+ "取消 + 回滚失败(install)→ 退回 failed,不报 killed",
3687
+ outcome.status === "failed",
3688
+ `status=${outcome.status} detail=${detail}`,
3689
+ );
3690
+ check(
3691
+ "取消 + 回滚失败(install)→ 绝不声称已还原",
3692
+ !/was restored/.test(detail),
3693
+ `detail=${detail}`,
3694
+ );
3695
+ check(
3696
+ "取消 + 回滚失败(install)→ 指名 marker 与排查手段",
3697
+ /marker/.test(detail) && /guard validate|guard recover|before the next start/.test(detail),
3698
+ `detail=${detail}`,
3699
+ );
3700
+ } finally {
3701
+ cleanup();
3702
+ }
3703
+ }
3704
+
3705
+ // 4b-3. pnpm 缺失 → corepack 自愈期间取消。此前 current 还指着那个 ENOENT
3706
+ // 失败的 pnpm,cancel() 什么也杀不到,corepack 跑完还会照常开装——按了
3707
+ // job_kill 之后仍然会改 profile。retry 的 spawn 次数必须是 0。
3708
+ {
3709
+ const { profileDir, cleanup } = makeTempProfile("corepack-cancel");
3710
+ try {
3711
+ materializeFakePackage(profileDir, "pkg-a", "1.0.0");
3712
+ let spawnCount = 0;
3713
+ const spawnFn = () => {
3714
+ spawnCount++;
3715
+ const proc = new FakeProc("win32");
3716
+ queueMicrotask(() => proc.emit("error", Object.assign(new Error("spawn pnpm ENOENT"), { code: "ENOENT" })));
3717
+ return proc;
3718
+ };
3719
+ let releaseCorepack;
3720
+ const corepackRunning = new Promise((resolveGate) => { releaseCorepack = resolveGate; });
3721
+ // corepack 的子进程必须真的被 cancel() 够到,而不只是「之后不再 spawn」。
3722
+ // 交出去的句柄没人杀的话,corepack 会在后台把 pnpm 装完,用户按的那次
3723
+ // 取消就只挡住了后半程。
3724
+ let corepackKilled = false;
3725
+ const install = runInstall({
3726
+ profile: "p",
3727
+ spec: "pkg-a",
3728
+ preflight: preflightStub("pkg-a"),
3729
+ _profileDir: profileDir,
3730
+ _spawn: spawnFn,
3731
+ _corepack: async (_push, onProc) => {
3732
+ onProc?.({ proc: { pid: 999, kill: () => { corepackKilled = true; } }, treeKill: false });
3733
+ await corepackRunning;
3734
+ return true; // 自愈"成功"——取消也必须挡住它后面的安装
3735
+ },
3736
+ });
3737
+ await flush();
3738
+ const spawnsBeforeCancel = spawnCount;
3739
+ install.cancel(); // corepack 还在跑
3740
+ releaseCorepack(); // 然后它成功返回——这一步之后绝不能再开装
3741
+ const outcome = await install.done;
3742
+ check(
3743
+ "corepack 自愈期间取消 → retry spawn 次数为 0",
3744
+ spawnCount === spawnsBeforeCancel && spawnCount === 1,
3745
+ `spawnCount=${spawnCount} beforeCancel=${spawnsBeforeCancel}`,
3746
+ );
3747
+ check(
3748
+ "corepack 自愈期间取消 → corepack 子进程确实收到 kill",
3749
+ corepackKilled === true,
3750
+ `corepackKilled=${corepackKilled}`,
3751
+ );
3752
+ check(
3753
+ "corepack 自愈期间取消 → 结局是 killed",
3754
+ outcome.status === "killed",
3755
+ `status=${outcome.status} detail=${outcome.detail}`,
3481
3756
  );
3482
3757
  } finally {
3483
3758
  cleanup();