@1e0zj/dsh-plugin-mall 0.4.0 → 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/README.md +2 -2
- package/package.json +1 -1
- package/src/cli.js +47 -1
- package/src/client.js +44 -4
- package/src/github.js +15 -8
- package/src/guard.js +209 -6
- package/src/index.js +748 -100
- package/src/installer.js +310 -35
- package/src/restart-protocol.js +144 -0
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
|
|
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
|
|
1870
|
+
if (endedByCancel(outcome, cancelRequested)) {
|
|
1813
1871
|
restoreOriginalWorkspace();
|
|
1814
|
-
return { status: "killed", detail: outcome
|
|
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
|
|
1902
|
-
return { status: "killed", detail: retryOutcome
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
2255
|
+
if (endedByCancel(outcome, cancelRequested)) {
|
|
2156
2256
|
// 取消也要还原:pnpm 可能已经删掉了 node_modules 里的目录。
|
|
2157
|
-
const
|
|
2158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|