@1e0zj/dsh-plugin-mall 0.4.0 → 0.4.1
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/index.js +232 -17
- package/src/restart-protocol.js +144 -0
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Curated lists only show what has been reviewed and merged. This marketplace is o
|
|
|
16
16
|
- **npm-first installs** — registry tarballs are smaller than whole-repo GitHub downloads and come with integrity checks. Lookups follow the registry pnpm actually installs from (profile `.npmrc` → `pnpm config get registry` → npmjs), so a mirror user keeps npm-first instead of silently falling back to whole-repo clones.
|
|
17
17
|
- **Update management** — installed plugins are compared against the registry `latest`; one-click update per plugin.
|
|
18
18
|
- **Conflict guard** — every install runs an isolated preflight first: the candidate is installed with scripts disabled into a throwaway directory and scanned against the live profile for loader-id collisions, double mounts, host-module shadowing and version/OS/peer ranges. A patch layer is not read entry by entry but composed the way the loader applies it — the profile is assembled with and without the candidate's layer, in the position dsh would apply it, and the two trees are diffed. That composition is checked against the loader's own `applyEntryPatches` on every test run, over fixed shapes and 300 generated patch combinations, so "what dsh would boot" is not a guess. What gets reported is what would actually change: disabling, re-enabling or replacing the config of someone else's rows warns and names them, past ten such rows the candidate is a rival composition rather than an addition and is blocked, and a bundle that is itself another front door (own `bin`, terminal peers, no browser half) is blocked once it switches rows on or off or rewrites a protection row — the fingerprint corroborates, it never blocks on its own. A hard conflict is blocked, warnings require explicit confirmation. The profile's load-bearing files are snapshotted before `pnpm` touches them and restored on failure. A pending install is resolved on the next start, however you start it: this plugin runs recovery as it loads, which only happens because dsh booted far enough to compose the profile. Starting through `guard launch` adds a grace window on top, so a plugin that boots and then crashes seconds later is rolled back and restarted once (see [Startup protection](#startup-protection-guard-cli)).
|
|
19
|
-
- **Resilience** — rate-limit circuit breaker, GitHub's 1000-result search window handled gracefully, `corepack enable pnpm` self-heal when pnpm is missing, one-click dsh restart (loopback-only, `allowRestart: false` to disable). The successor waits for the outgoing host to be gone before it binds — starting into an occupied port would read as "the pending plugin crashed dsh" and roll back an install that was fine. It leaves no stray console behind and does not re-open the browser, because the page that asked for the restart is still there and reconnects on its own; whatever the restart prints lands in `<home>/guard/restart-<profile>.log`.
|
|
19
|
+
- **Resilience** — rate-limit circuit breaker, GitHub's 1000-result search window handled gracefully, `corepack enable pnpm` self-heal when pnpm is missing, one-click dsh restart (loopback-only, `allowRestart: false` to disable). The outgoing Host exits only after the on-disk helper explicitly acknowledges the current handoff protocol and stays alive through a stability window; a missing, stale or incompatible helper leaves the existing Web service running. The successor then waits for the outgoing host to be gone before it binds — starting into an occupied port would read as "the pending plugin crashed dsh" and roll back an install that was fine. It leaves no stray console behind and does not re-open the browser, because the page that asked for the restart is still there and reconnects on its own; whatever the restart prints lands in `<home>/guard/restart-<profile>.log`.
|
|
20
20
|
|
|
21
21
|
## Install
|
|
22
22
|
|
|
@@ -154,7 +154,7 @@ pnpm --dir <profile> add "github:omdsh-dev/dsh-at-file" --ignore-scripts
|
|
|
154
154
|
- **npm 优先安装**:registry tarball 比整仓库下载更小且带完整性校验;查询用的 registry 跟随 pnpm 实际安装源(profile `.npmrc` → `pnpm config get registry` → npmjs),换了镜像也不会退化成整仓库克隆
|
|
155
155
|
- **更新管理**:已装插件与 registry `latest` 比对,逐个一键更新
|
|
156
156
|
- **冲突防护**:每次安装先跑隔离预检——候选包在一次性目录里以禁用脚本的方式装好后,对照 live profile 扫描 loader-id 冲突、重复挂载、宿主模块遮蔽和版本/OS/peer 范围。补丁不是逐条读,而是**按 loader 的方式组装**——把候选包的层放进 dsh 会放的位置,装前装后各组装一棵树再逐行比对。这套组装每次跑测试都会和 loader 自己的 `applyEntryPatches` 对拍(固定形状 + 300 组随机生成的补丁组合),所以「dsh 会装出什么树」不是猜的。报出来的是真正会变的东西:停用、重新启用、或替换别人整块 config,都点名是哪几条 id 并警告;超过十条就不是叠加而是另一套组合,直接拦截;候选包本身就是另一套门面(自带 bin、依赖终端栈、没有浏览器半边)时,只要它停用/启用了行、或改了沙箱审批这类保护行,就直接拦截——指纹只做佐证,单凭它不拦。硬冲突直接拦截,警告需显式确认。安装前给 profile 的承重文件拍快照、失败即回滚;pending 安装在下次启动时自动了结,**不挑启动方式**:本插件加载时就跑恢复,而能加载本身就证明 dsh 已经组装好 profile、活到了这一步。经 `guard launch` 启动则多一层观察期——插件启动几秒后才崩的情况也能回滚并原样重启一次(见下方「启动保护」)。
|
|
157
|
-
- **工程韧性**:限流熔断、GitHub 5xx/超时退避重试(504 瞬时故障不再直达用户)、GitHub 1000 条搜索上限优雅处理、pnpm 缺失时 `corepack` 自愈、一键重启 dsh(仅 loopback,可 `allowRestart: false`
|
|
157
|
+
- **工程韧性**:限流熔断、GitHub 5xx/超时退避重试(504 瞬时故障不再直达用户)、GitHub 1000 条搜索上限优雅处理、pnpm 缺失时 `corepack` 自愈、一键重启 dsh(仅 loopback,可 `allowRestart: false` 关闭)。旧 Host 只有在磁盘上的 helper 明确回报当前交接协议、并活过稳定窗口后才退出;helper 缺失、过旧或协议不兼容时,现有 Web 服务继续运行。之后 successor 会**等旧进程真正退出**再绑端口——抢在端口没释放时启动,会被判成「新装的插件把 dsh 搞崩了」,把一次本来正常的安装回滚掉。重启后不留控制台窗口、也不再重开浏览器:发起重启的那个页面还在,会自己重连;重启过程的输出写进 `<home>/guard/restart-<profile>.log`(想停 dsh 用任务管理器结束 node 进程)
|
|
158
158
|
|
|
159
159
|
## 安装
|
|
160
160
|
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
// github.js imports node builtins only — the host-independence of this CLI is
|
|
63
63
|
// preserved (it must keep working when the dsh host itself is broken).
|
|
64
64
|
import { npmPackageInfo } from "./github.js";
|
|
65
|
+
import { createRestartHelperReadyMessage } from "./restart-protocol.js";
|
|
65
66
|
|
|
66
67
|
/**
|
|
67
68
|
* Pin a bare package name to name@latest: pnpm's minimumReleaseAge policy
|
|
@@ -79,6 +80,24 @@ export async function pinSpecToLatest(spec, npmInfo = npmPackageInfo) {
|
|
|
79
80
|
return spec;
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Tell the outgoing Web Host that this exact CLI understands its handoff
|
|
85
|
+
* protocol and has finished parsing --await-exit. A normal terminal launch has
|
|
86
|
+
* no IPC channel and simply skips the announcement.
|
|
87
|
+
*/
|
|
88
|
+
export async function announceRestartHelperReady(awaitExitPid, send = process.send?.bind(process)) {
|
|
89
|
+
if (awaitExitPid === undefined || typeof send !== "function") return false;
|
|
90
|
+
const message = createRestartHelperReadyMessage(awaitExitPid);
|
|
91
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
92
|
+
try {
|
|
93
|
+
send(message, (error) => error ? rejectPromise(error) : resolvePromise());
|
|
94
|
+
} catch (error) {
|
|
95
|
+
rejectPromise(error);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
82
101
|
// ── small helpers ────────────────────────────────────────────────────────────
|
|
83
102
|
|
|
84
103
|
// Same shell-metacharacter blocklist as installer.assertSafeSpec. The install
|
|
@@ -884,7 +903,15 @@ function markerLooksValid(marker, profileDir, home) {
|
|
|
884
903
|
* A corrupt or legacy (pre-v2) marker, or a failed static-recovery step, fails
|
|
885
904
|
* closed: the command is NOT launched and no unvalidated path is deleted.
|
|
886
905
|
*/
|
|
887
|
-
async function cmdLaunch({
|
|
906
|
+
async function cmdLaunch({
|
|
907
|
+
profile,
|
|
908
|
+
home,
|
|
909
|
+
graceMs,
|
|
910
|
+
commandArgv,
|
|
911
|
+
awaitExitPid,
|
|
912
|
+
_waitForExit = waitForProcessExit,
|
|
913
|
+
_onAwaitExit = announceRestartHelperReady,
|
|
914
|
+
}) {
|
|
888
915
|
const profileDir = profileDirOf(home, profile);
|
|
889
916
|
const grace = graceMs ?? DEFAULT_GRACE_MS;
|
|
890
917
|
const [command, ...args] = commandArgv;
|
|
@@ -899,6 +926,10 @@ async function cmdLaunch({ profile, home, graceMs, commandArgv, awaitExitPid, _w
|
|
|
899
926
|
// the outcome that costs the user their install; not starting leaves the old
|
|
900
927
|
// host running and the marker pending, which the next launch resolves.
|
|
901
928
|
if (awaitExitPid !== undefined) {
|
|
929
|
+
// This is the exact handoff boundary: arguments and profile are valid, and
|
|
930
|
+
// the helper is about to block on the outgoing Host. The Web parent waits
|
|
931
|
+
// for this IPC acknowledgement before it allows itself to exit.
|
|
932
|
+
await _onAwaitExit(awaitExitPid);
|
|
902
933
|
if (!await _waitForExit(awaitExitPid, AWAIT_EXIT_TIMEOUT_MS)) {
|
|
903
934
|
throw new Error(`process ${awaitExitPid} was still running after ${AWAIT_EXIT_TIMEOUT_MS}ms — refusing to start a second host that would collide with it on the listening port (the old one is still up; nothing was changed)`);
|
|
904
935
|
}
|
|
@@ -1184,6 +1215,18 @@ async function selfTest() {
|
|
|
1184
1215
|
// A pid after `--` belongs to the wrapped command, not to us.
|
|
1185
1216
|
const p6 = parseArgs(["launch", "--profile", "web", "--", "dsh", "--await-exit", "7"]);
|
|
1186
1217
|
if (p6.awaitExit !== undefined || p6.commandArgv.join(" ") !== "dsh --await-exit 7") throw new Error("--await-exit after `--` must stay with the wrapped command");
|
|
1218
|
+
|
|
1219
|
+
let announced;
|
|
1220
|
+
const didAnnounce = await announceRestartHelperReady(4321, (message, callback) => {
|
|
1221
|
+
announced = message;
|
|
1222
|
+
callback();
|
|
1223
|
+
});
|
|
1224
|
+
if (!didAnnounce || announced?.awaitExitPid !== 4321 || announced?.protocol !== 1) {
|
|
1225
|
+
throw new Error("restart helper must announce the parsed pid and protocol over IPC");
|
|
1226
|
+
}
|
|
1227
|
+
if (await announceRestartHelperReady(undefined, () => { throw new Error("must not send"); }) !== false) {
|
|
1228
|
+
throw new Error("a normal launch without --await-exit must not announce a restart handoff");
|
|
1229
|
+
}
|
|
1187
1230
|
}
|
|
1188
1231
|
|
|
1189
1232
|
// `--await-exit`: the successor must not start while the outgoing host is
|
|
@@ -1199,6 +1242,7 @@ async function selfTest() {
|
|
|
1199
1242
|
|
|
1200
1243
|
let launched = false;
|
|
1201
1244
|
const neverExits = async () => false;
|
|
1245
|
+
let announcedPid;
|
|
1202
1246
|
let refused = false;
|
|
1203
1247
|
try {
|
|
1204
1248
|
await cmdLaunch({
|
|
@@ -1207,11 +1251,13 @@ async function selfTest() {
|
|
|
1207
1251
|
commandArgv: [process.execPath, "-e", "launched=1"],
|
|
1208
1252
|
awaitExitPid: 999999,
|
|
1209
1253
|
_waitForExit: neverExits,
|
|
1254
|
+
_onAwaitExit: async (pid) => { announcedPid = pid; },
|
|
1210
1255
|
});
|
|
1211
1256
|
} catch (error) {
|
|
1212
1257
|
refused = /still running after/.test(error.message);
|
|
1213
1258
|
}
|
|
1214
1259
|
if (!refused) throw new Error("a pid that outlives the timeout must refuse the launch, not race it");
|
|
1260
|
+
if (announcedPid !== 999999) throw new Error("launch must announce readiness immediately before waiting for the outgoing pid");
|
|
1215
1261
|
|
|
1216
1262
|
// …and a pid that does go away lets the command through.
|
|
1217
1263
|
const exitsPromptly = async () => true;
|
package/src/client.js
CHANGED
|
@@ -661,6 +661,25 @@ window.__ModuleLoader__.load({
|
|
|
661
661
|
// No boolean warning-consent map is kept.
|
|
662
662
|
var sessionNonce = useRef(generateSessionNonce()).current;
|
|
663
663
|
var approvalTokensRef = useRef({});
|
|
664
|
+
var restartControlRef = useRef({ mounted: true, ping: null });
|
|
665
|
+
var clearRestartPing = useCallback(function () {
|
|
666
|
+
var control = restartControlRef.current;
|
|
667
|
+
if (control.ping !== null) {
|
|
668
|
+
clearInterval(control.ping);
|
|
669
|
+
control.ping = null;
|
|
670
|
+
}
|
|
671
|
+
}, []);
|
|
672
|
+
useEffect(function () {
|
|
673
|
+
var control = restartControlRef.current;
|
|
674
|
+
control.mounted = true;
|
|
675
|
+
return function () {
|
|
676
|
+
control.mounted = false;
|
|
677
|
+
if (control.ping !== null) {
|
|
678
|
+
clearInterval(control.ping);
|
|
679
|
+
control.ping = null;
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
}, []);
|
|
664
683
|
|
|
665
684
|
var call = useCallback(function (endpoint, payload) {
|
|
666
685
|
var body = Object.assign({ session: sessionNonce }, payload || {});
|
|
@@ -887,28 +906,49 @@ window.__ModuleLoader__.load({
|
|
|
887
906
|
if (typeof window !== "undefined" && typeof window.confirm === "function") {
|
|
888
907
|
if (window.confirm("重启 dsh?正在进行的任务会中断。") !== true) return;
|
|
889
908
|
}
|
|
909
|
+
clearRestartPing();
|
|
890
910
|
setRestarting(true);
|
|
891
911
|
setError(null);
|
|
912
|
+
var requestedAt = Date.now();
|
|
913
|
+
var previousHostStartedAt = Number(hostStartedAt);
|
|
892
914
|
call("restart", {}).then(function () {
|
|
915
|
+
if (restartControlRef.current.mounted !== true) return;
|
|
893
916
|
var tries = 0;
|
|
894
917
|
var ping = setInterval(function () {
|
|
918
|
+
if (restartControlRef.current.mounted !== true) {
|
|
919
|
+
clearRestartPing();
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
895
922
|
tries++;
|
|
896
923
|
if (tries > 40) {
|
|
897
|
-
|
|
924
|
+
clearRestartPing();
|
|
898
925
|
setRestarting(false);
|
|
899
926
|
setError("2 分钟内未检测到 dsh 重启完成,请手动检查 dsh 状态后刷新页面。");
|
|
900
927
|
return;
|
|
901
928
|
}
|
|
902
|
-
|
|
903
|
-
|
|
929
|
+
// Prefer identity change over wall-clock ordering: NTP may move the
|
|
930
|
+
// successor's timeOrigin backwards. If initial restore did not
|
|
931
|
+
// provide an identity, retain the stricter requestedAt fallback.
|
|
932
|
+
call("jobs", {}).then(function (value) {
|
|
933
|
+
if (restartControlRef.current.mounted !== true) return;
|
|
934
|
+
var observedHostStartedAt = Number(value.hostStartedAt);
|
|
935
|
+
if (!Number.isFinite(observedHostStartedAt) || observedHostStartedAt <= 0) return;
|
|
936
|
+
var hadPreviousIdentity = Number.isFinite(previousHostStartedAt) && previousHostStartedAt > 0;
|
|
937
|
+
if (hadPreviousIdentity
|
|
938
|
+
? observedHostStartedAt === previousHostStartedAt
|
|
939
|
+
: observedHostStartedAt <= requestedAt) return;
|
|
940
|
+
clearRestartPing();
|
|
904
941
|
window.location.reload();
|
|
905
942
|
}).catch(function () { /* host restarting */ });
|
|
906
943
|
}, 3000);
|
|
944
|
+
restartControlRef.current.ping = ping;
|
|
907
945
|
}).catch(function (e) {
|
|
946
|
+
if (restartControlRef.current.mounted !== true) return;
|
|
947
|
+
clearRestartPing();
|
|
908
948
|
setRestarting(false);
|
|
909
949
|
setError(errorText(e));
|
|
910
950
|
});
|
|
911
|
-
}, [call,
|
|
951
|
+
}, [call, clearRestartPing, hostStartedAt]);
|
|
912
952
|
|
|
913
953
|
var doUninstall = useCallback(function (name) {
|
|
914
954
|
delete approvalTokensRef.current[name];
|
package/src/index.js
CHANGED
|
@@ -19,6 +19,8 @@ import { existsSync, readFileSync, realpathSync, mkdirSync, mkdtempSync, writeFi
|
|
|
19
19
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
20
20
|
import { spawn } from "node:child_process";
|
|
21
21
|
import { createHash, randomBytes } from "node:crypto";
|
|
22
|
+
import { EventEmitter } from "node:events";
|
|
23
|
+
import { performance } from "node:perf_hooks";
|
|
22
24
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
23
25
|
import { createRequire } from "node:module";
|
|
24
26
|
import { tmpdir } from "node:os";
|
|
@@ -26,6 +28,7 @@ import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
|
|
|
26
28
|
import { repoInfo, searchPlugins, verifyPlugins, cachedRepoManifest, fetchRawFile, preferNpmSpec, npmPackageInfo, compareVersions, assertSafeToInstall, mapLimit, NETWORK_CONCURRENCY } from "./github.js";
|
|
27
29
|
import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, assertSafeSpec, resolveRegistry, serializeCanonicalProof, persistPluginDisabled } from "./installer.js";
|
|
28
30
|
import { preflightInstall, inspectRemoteCandidate, recoverProfile, describeRollbackRebuild } from "./guard.js";
|
|
31
|
+
import { createRestartHelperReadyMessage, RESTART_HELPER_READY_TYPE, RESTART_RESPONSE_DRAIN_MS, superviseRestartHelper } from "./restart-protocol.js";
|
|
29
32
|
|
|
30
33
|
export const name = "@1e0zj/dsh-plugin-mall";
|
|
31
34
|
// `loader` 用来读装配树、并对单个 entry 做热开关(entry.update)。读法照抄
|
|
@@ -288,12 +291,10 @@ export function computeProfileFingerprint(profileDir) {
|
|
|
288
291
|
const compatCache = new Map();
|
|
289
292
|
const COMPAT_TTL = 600000;
|
|
290
293
|
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
// the task's Restart-dsh button has already done its job.
|
|
296
|
-
const pluginLoadedAt = Date.now();
|
|
294
|
+
// Node captures performance.timeOrigin once at process startup. It therefore
|
|
295
|
+
// stays byte-for-byte stable across Cordis remounts/HMR while still changing
|
|
296
|
+
// for a successor process, even if the wall clock is corrected backwards.
|
|
297
|
+
const hostProcessStartedAt = Math.floor(performance.timeOrigin);
|
|
297
298
|
|
|
298
299
|
function compatCacheGet(key) {
|
|
299
300
|
const cached = compatCache.get(key);
|
|
@@ -923,6 +924,23 @@ export function restartLogPath(profileDir, profile) {
|
|
|
923
924
|
return join(dirname(dirname(profileDir)), "guard", `restart-${profile}.log`);
|
|
924
925
|
}
|
|
925
926
|
|
|
927
|
+
function appendRestartDiagnostic(logPath, message) {
|
|
928
|
+
const line = `[dsh-plugin-mall] ${message}\n`;
|
|
929
|
+
console.error(line.trimEnd());
|
|
930
|
+
if (logPath === undefined) return;
|
|
931
|
+
let fd;
|
|
932
|
+
try {
|
|
933
|
+
fd = openSync(logPath, "a");
|
|
934
|
+
writeSync(fd, line);
|
|
935
|
+
} catch {
|
|
936
|
+
/* the console diagnostic is still useful */
|
|
937
|
+
} finally {
|
|
938
|
+
if (fd !== undefined) {
|
|
939
|
+
try { closeSync(fd); } catch { /* already closed */ }
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
926
944
|
// ── in-process job tracker for browser RPC ───────────────────────────────────
|
|
927
945
|
|
|
928
946
|
let trackerCounter = 0;
|
|
@@ -1713,7 +1731,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
1713
1731
|
// dropping all React state. The task records live here.
|
|
1714
1732
|
try {
|
|
1715
1733
|
const session = requireBrowserSession(payload?.session);
|
|
1716
|
-
return rpcOk({ jobs: tracker.list(session), hostStartedAt:
|
|
1734
|
+
return rpcOk({ jobs: tracker.list(session), hostStartedAt: hostProcessStartedAt });
|
|
1717
1735
|
} catch (error) {
|
|
1718
1736
|
return rpcFail(error);
|
|
1719
1737
|
}
|
|
@@ -1727,6 +1745,12 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
1727
1745
|
}
|
|
1728
1746
|
const plan = resolveRestartLaunchPlan({ profile, config });
|
|
1729
1747
|
if (!plan.ok) {
|
|
1748
|
+
let diagnosticPath;
|
|
1749
|
+
try {
|
|
1750
|
+
diagnosticPath = restartLogPath(resolveProfileDir(profile), profile);
|
|
1751
|
+
mkdirSync(dirname(diagnosticPath), { recursive: true });
|
|
1752
|
+
} catch { /* invalid profile/home: console remains the diagnostic sink */ }
|
|
1753
|
+
appendRestartDiagnostic(diagnosticPath, `restart plan rejected: ${plan.error}; old Host remains running`);
|
|
1730
1754
|
return rpcFail(new Error(plan.error));
|
|
1731
1755
|
}
|
|
1732
1756
|
// Everything the restart prints goes to a file. `stdio: "ignore"` used to
|
|
@@ -1745,17 +1769,50 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
1745
1769
|
console.error(`[dsh-plugin-mall] restart log unavailable (${error.message}); continuing without it`);
|
|
1746
1770
|
logFd = undefined;
|
|
1747
1771
|
}
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1772
|
+
let child;
|
|
1773
|
+
try {
|
|
1774
|
+
child = spawn(plan.nodePath, plan.args, {
|
|
1775
|
+
shell: false,
|
|
1776
|
+
detached: true,
|
|
1777
|
+
// fd 3 is an IPC channel used only for the readiness handshake. An
|
|
1778
|
+
// old/incompatible CLI either exits on --await-exit or times out; it
|
|
1779
|
+
// can never make the current Host leave merely by spawning.
|
|
1780
|
+
stdio: ["ignore", logFd ?? "ignore", logFd ?? "ignore", "ipc"],
|
|
1781
|
+
cwd: process.cwd(),
|
|
1782
|
+
windowsHide: true,
|
|
1783
|
+
});
|
|
1784
|
+
} catch (error) {
|
|
1785
|
+
if (logFd !== undefined) closeSync(logFd);
|
|
1786
|
+
appendRestartDiagnostic(logPath, `restart helper could not be spawned: ${error.message}; old Host remains running`);
|
|
1787
|
+
return rpcFail(new Error(`automatic restart helper could not be spawned; the current dsh is still running${logPath ? ` (see ${logPath})` : ""}`));
|
|
1788
|
+
}
|
|
1789
|
+
if (logFd !== undefined) closeSync(logFd); // the child holds its own duplicates
|
|
1790
|
+
|
|
1791
|
+
const handoff = superviseRestartHelper(child, {
|
|
1792
|
+
awaitExitPid: plan.awaitExitPid,
|
|
1793
|
+
onFailure: (message) => appendRestartDiagnostic(logPath, `${message}; old Host remains running`),
|
|
1754
1794
|
});
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1795
|
+
let disposeHandoffEffect;
|
|
1796
|
+
try {
|
|
1797
|
+
// Cordis runs this disposer on HMR/config unload. In particular, the
|
|
1798
|
+
// process-exit timer can no longer outlive the plugin instance that
|
|
1799
|
+
// created it and kill the Host one second later.
|
|
1800
|
+
disposeHandoffEffect = ctx.effect(
|
|
1801
|
+
() => handoff.dispose,
|
|
1802
|
+
"@1e0zj/dsh-plugin-mall: restart handoff",
|
|
1803
|
+
);
|
|
1804
|
+
} catch (error) {
|
|
1805
|
+
handoff.dispose();
|
|
1806
|
+
appendRestartDiagnostic(logPath, `restart handoff could not join the plugin lifecycle: ${error.message}; old Host remains running`);
|
|
1807
|
+
return rpcFail(new Error("automatic restart was cancelled because the marketplace plugin is unloading; the current dsh is still running"));
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
const accepted = await handoff.ready;
|
|
1811
|
+
if (!accepted.ok) {
|
|
1812
|
+
await disposeHandoffEffect();
|
|
1813
|
+
return rpcFail(new Error(`${accepted.error}; the current dsh is still running${logPath ? ` (see ${logPath})` : ""}`));
|
|
1814
|
+
}
|
|
1815
|
+
return rpcOk({ restarting: true, handoffAccepted: true, logPath });
|
|
1759
1816
|
}
|
|
1760
1817
|
case "jobCancel": {
|
|
1761
1818
|
try {
|
|
@@ -2627,6 +2684,164 @@ export async function runSelfTests() {
|
|
|
2627
2684
|
|
|
2628
2685
|
check("重启日志落在 <home>/guard/ 下", restartLogPath(join("/h", "profiles", "web"), "web").replace(/\\/g, "/").endsWith("/h/guard/restart-web.log"));
|
|
2629
2686
|
|
|
2687
|
+
{
|
|
2688
|
+
const dshHomeBefore = process.env.DSH_HOME;
|
|
2689
|
+
const consoleErrorBefore = console.error;
|
|
2690
|
+
const diagnostics = [];
|
|
2691
|
+
const fixtureHome = join(root, "restart-plan-home");
|
|
2692
|
+
let response;
|
|
2693
|
+
let logged = "";
|
|
2694
|
+
try {
|
|
2695
|
+
process.env.DSH_HOME = fixtureHome;
|
|
2696
|
+
console.error = (...args) => { diagnostics.push(args.join(" ")); };
|
|
2697
|
+
response = await rpcDispatch(
|
|
2698
|
+
{},
|
|
2699
|
+
"restart",
|
|
2700
|
+
{ profile: "web", session: `sess_${"a".repeat(32)}` },
|
|
2701
|
+
{ defaultProfile: "web", allowRestart: false },
|
|
2702
|
+
undefined,
|
|
2703
|
+
{},
|
|
2704
|
+
);
|
|
2705
|
+
logged = readFileSync(join(fixtureHome, "guard", "restart-web.log"), "utf8");
|
|
2706
|
+
} finally {
|
|
2707
|
+
console.error = consoleErrorBefore;
|
|
2708
|
+
if (dshHomeBefore === undefined) delete process.env.DSH_HOME;
|
|
2709
|
+
else process.env.DSH_HOME = dshHomeBefore;
|
|
2710
|
+
}
|
|
2711
|
+
check(
|
|
2712
|
+
"重启 plan 失败 → 页面返回原错误且诊断同时落盘",
|
|
2713
|
+
response?.ok === false && /restart disabled/.test(response.error?.message)
|
|
2714
|
+
&& /restart plan rejected: restart disabled/.test(logged)
|
|
2715
|
+
&& diagnostics.some((line) => /restart plan rejected/.test(line)),
|
|
2716
|
+
);
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
// The restart helper is a protocol peer, not merely a spawned pid. These
|
|
2720
|
+
// fixtures pin the fail-safe: every pre-handoff failure keeps the old Host
|
|
2721
|
+
// alive, while only an explicit, stable readiness message permits exit.
|
|
2722
|
+
{
|
|
2723
|
+
class FakeRestartChild extends EventEmitter {
|
|
2724
|
+
constructor() {
|
|
2725
|
+
super();
|
|
2726
|
+
this.connected = true;
|
|
2727
|
+
this.exitCode = null;
|
|
2728
|
+
this.signalCode = null;
|
|
2729
|
+
this.killed = false;
|
|
2730
|
+
this.killCalls = 0;
|
|
2731
|
+
this.unrefCalls = 0;
|
|
2732
|
+
this.disconnectCalls = 0;
|
|
2733
|
+
}
|
|
2734
|
+
kill() { this.killed = true; this.killCalls++; }
|
|
2735
|
+
unref() { this.unrefCalls++; }
|
|
2736
|
+
disconnect() { this.connected = false; this.disconnectCalls++; }
|
|
2737
|
+
exit(code, signal = null) {
|
|
2738
|
+
this.exitCode = code;
|
|
2739
|
+
this.signalCode = signal;
|
|
2740
|
+
this.emit("exit", code, signal);
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
const wait = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
2744
|
+
const supervise = (child, overrides = {}) => {
|
|
2745
|
+
let hostExits = 0;
|
|
2746
|
+
const failures = [];
|
|
2747
|
+
const handoff = superviseRestartHelper(child, {
|
|
2748
|
+
awaitExitPid: 4242,
|
|
2749
|
+
handshakeTimeoutMs: 30,
|
|
2750
|
+
stabilityMs: 4,
|
|
2751
|
+
responseDelayMs: 4,
|
|
2752
|
+
onHostExit: () => { hostExits++; },
|
|
2753
|
+
onFailure: (message, meta) => { failures.push({ message, meta }); },
|
|
2754
|
+
...overrides,
|
|
2755
|
+
});
|
|
2756
|
+
return { handoff, failures, hostExits: () => hostExits };
|
|
2757
|
+
};
|
|
2758
|
+
|
|
2759
|
+
const spawnErrorChild = new FakeRestartChild();
|
|
2760
|
+
const spawnError = supervise(spawnErrorChild);
|
|
2761
|
+
spawnErrorChild.emit("error", new Error("ENOENT"));
|
|
2762
|
+
const spawnErrorReady = await spawnError.handoff.ready;
|
|
2763
|
+
await wait(10);
|
|
2764
|
+
check(
|
|
2765
|
+
"重启 helper spawn error → 旧 Host 不退出且 error listener 已清理",
|
|
2766
|
+
!spawnErrorReady.ok && /ENOENT/.test(spawnErrorReady.error)
|
|
2767
|
+
&& spawnError.hostExits() === 0
|
|
2768
|
+
&& spawnErrorChild.listenerCount("error") === 0,
|
|
2769
|
+
);
|
|
2770
|
+
|
|
2771
|
+
const usageChild = new FakeRestartChild();
|
|
2772
|
+
const usage = supervise(usageChild);
|
|
2773
|
+
usageChild.exit(2);
|
|
2774
|
+
const usageReady = await usage.handoff.ready;
|
|
2775
|
+
await wait(10);
|
|
2776
|
+
check(
|
|
2777
|
+
"旧 CLI 因 --await-exit 以 code 2 快速退出 → 旧 Host 保持运行",
|
|
2778
|
+
!usageReady.ok && /code 2/.test(usageReady.error) && usage.hostExits() === 0,
|
|
2779
|
+
);
|
|
2780
|
+
|
|
2781
|
+
const silentChild = new FakeRestartChild();
|
|
2782
|
+
const silent = supervise(silentChild, { handshakeTimeoutMs: 8 });
|
|
2783
|
+
const silentReady = await silent.handoff.ready;
|
|
2784
|
+
check(
|
|
2785
|
+
"helper 建立后静默至握手超时 → 终止 helper 且旧 Host 保持运行",
|
|
2786
|
+
!silentReady.ok && /did not acknowledge/.test(silentReady.error)
|
|
2787
|
+
&& silentChild.killCalls === 1 && silent.hostExits() === 0
|
|
2788
|
+
&& silentChild.listenerCount("message") === 0,
|
|
2789
|
+
);
|
|
2790
|
+
|
|
2791
|
+
const mismatchChild = new FakeRestartChild();
|
|
2792
|
+
const mismatch = supervise(mismatchChild);
|
|
2793
|
+
mismatchChild.emit("message", { type: RESTART_HELPER_READY_TYPE, protocol: 99, awaitExitPid: 4242 });
|
|
2794
|
+
const mismatchReady = await mismatch.handoff.ready;
|
|
2795
|
+
check(
|
|
2796
|
+
"同版本不同内容/协议不匹配 → fail closed,不靠 package version 放行",
|
|
2797
|
+
!mismatchReady.ok && /protocol mismatch/.test(mismatchReady.error)
|
|
2798
|
+
&& mismatchChild.killCalls === 1 && mismatch.hostExits() === 0,
|
|
2799
|
+
);
|
|
2800
|
+
|
|
2801
|
+
const unstableChild = new FakeRestartChild();
|
|
2802
|
+
const unstable = supervise(unstableChild, { stabilityMs: 20 });
|
|
2803
|
+
unstableChild.emit("message", createRestartHelperReadyMessage(4242));
|
|
2804
|
+
unstableChild.exit(1);
|
|
2805
|
+
const unstableReady = await unstable.handoff.ready;
|
|
2806
|
+
check(
|
|
2807
|
+
"helper 握手后在稳定窗口内退出 → 取消旧 Host 退出",
|
|
2808
|
+
!unstableReady.ok && unstable.hostExits() === 0,
|
|
2809
|
+
);
|
|
2810
|
+
|
|
2811
|
+
const healthyChild = new FakeRestartChild();
|
|
2812
|
+
const healthy = supervise(healthyChild);
|
|
2813
|
+
healthyChild.emit("message", createRestartHelperReadyMessage(4242));
|
|
2814
|
+
const healthyReady = await healthy.handoff.ready;
|
|
2815
|
+
await wait(12);
|
|
2816
|
+
check(
|
|
2817
|
+
"helper 明确握手并活过稳定窗口 → 旧 Host 只退出一次",
|
|
2818
|
+
healthyReady.ok && healthy.hostExits() === 1
|
|
2819
|
+
&& healthyChild.disconnectCalls === 1 && healthyChild.unrefCalls === 1,
|
|
2820
|
+
);
|
|
2821
|
+
|
|
2822
|
+
const disposedChild = new FakeRestartChild();
|
|
2823
|
+
const disposed = supervise(disposedChild, { responseDelayMs: 40 });
|
|
2824
|
+
disposedChild.emit("message", createRestartHelperReadyMessage(4242));
|
|
2825
|
+
const disposedReady = await disposed.handoff.ready;
|
|
2826
|
+
disposed.handoff.dispose(); // mirrors the disposer returned from ctx.effect()
|
|
2827
|
+
await wait(50);
|
|
2828
|
+
check(
|
|
2829
|
+
"插件卸载/HMR disposer → 清理退出 timer、结束 helper、旧 Host 不退出",
|
|
2830
|
+
disposedReady.ok && disposed.hostExits() === 0 && disposedChild.killCalls === 1
|
|
2831
|
+
&& disposedChild.listenerCount("exit") === 0,
|
|
2832
|
+
);
|
|
2833
|
+
|
|
2834
|
+
check(
|
|
2835
|
+
"默认保留旧实现的一秒 RPC 响应排空窗口",
|
|
2836
|
+
RESTART_RESPONSE_DRAIN_MS === 1000,
|
|
2837
|
+
);
|
|
2838
|
+
check(
|
|
2839
|
+
"进程启动标识来自稳定的 performance.timeOrigin",
|
|
2840
|
+
Number.isFinite(hostProcessStartedAt)
|
|
2841
|
+
&& hostProcessStartedAt > 0 && hostProcessStartedAt <= Date.now(),
|
|
2842
|
+
);
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2630
2845
|
// ── 7. Tracker isolation: producer.done rejection handling ───────────────
|
|
2631
2846
|
let settledOutcome = null;
|
|
2632
2847
|
const rejectingProducer = {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Host-independent restart handoff protocol shared by the Web plugin and the
|
|
2
|
+
// standalone guard CLI. The parent must not infer readiness from a child pid:
|
|
3
|
+
// an incompatible CLI can spawn successfully and then die on argument parsing.
|
|
4
|
+
|
|
5
|
+
export const RESTART_HELPER_READY_TYPE = "@1e0zj/dsh-plugin-mall:restart-helper-ready";
|
|
6
|
+
export const RESTART_HELPER_PROTOCOL_VERSION = 1;
|
|
7
|
+
export const RESTART_RESPONSE_DRAIN_MS = 1000;
|
|
8
|
+
|
|
9
|
+
export function createRestartHelperReadyMessage(awaitExitPid) {
|
|
10
|
+
return {
|
|
11
|
+
type: RESTART_HELPER_READY_TYPE,
|
|
12
|
+
protocol: RESTART_HELPER_PROTOCOL_VERSION,
|
|
13
|
+
awaitExitPid,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function describeExit(code, signal) {
|
|
18
|
+
if (typeof code === "number") return ` with code ${code}`;
|
|
19
|
+
if (signal) return ` from signal ${signal}`;
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Keep the outgoing Host alive until the detached helper explicitly confirms
|
|
25
|
+
* that it parsed the current protocol and is waiting for that Host's pid.
|
|
26
|
+
*
|
|
27
|
+
* The returned disposer is suitable for ctx.effect(): unloading the plugin
|
|
28
|
+
* cancels every timer, removes listeners, and terminates the waiting helper.
|
|
29
|
+
*/
|
|
30
|
+
export function superviseRestartHelper(child, {
|
|
31
|
+
awaitExitPid,
|
|
32
|
+
handshakeTimeoutMs = 2000,
|
|
33
|
+
stabilityMs = 600,
|
|
34
|
+
responseDelayMs = RESTART_RESPONSE_DRAIN_MS,
|
|
35
|
+
onHostExit = () => process.exit(0),
|
|
36
|
+
onFailure = () => {},
|
|
37
|
+
} = {}) {
|
|
38
|
+
let phase = "handshake";
|
|
39
|
+
let timer;
|
|
40
|
+
let readySettled = false;
|
|
41
|
+
let resolveReady;
|
|
42
|
+
const ready = new Promise((resolvePromise) => { resolveReady = resolvePromise; });
|
|
43
|
+
|
|
44
|
+
const clearTimer = () => {
|
|
45
|
+
if (timer === undefined) return;
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
timer = undefined;
|
|
48
|
+
};
|
|
49
|
+
const removeListeners = () => {
|
|
50
|
+
child.removeListener("message", onMessage);
|
|
51
|
+
child.removeListener("error", onError);
|
|
52
|
+
child.removeListener("exit", onExit);
|
|
53
|
+
};
|
|
54
|
+
const terminateChild = () => {
|
|
55
|
+
if (child.exitCode !== null && child.exitCode !== undefined) return;
|
|
56
|
+
if (child.signalCode !== null && child.signalCode !== undefined) return;
|
|
57
|
+
if (child.killed === true) return;
|
|
58
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
59
|
+
};
|
|
60
|
+
const settleReady = (result) => {
|
|
61
|
+
if (readySettled) return;
|
|
62
|
+
readySettled = true;
|
|
63
|
+
resolveReady(result);
|
|
64
|
+
};
|
|
65
|
+
const fail = (message, { terminate = false } = {}) => {
|
|
66
|
+
if (phase === "failed" || phase === "disposed" || phase === "committed") return;
|
|
67
|
+
const afterReady = readySettled;
|
|
68
|
+
phase = "failed";
|
|
69
|
+
clearTimer();
|
|
70
|
+
removeListeners();
|
|
71
|
+
if (terminate) terminateChild();
|
|
72
|
+
settleReady({ ok: false, error: message });
|
|
73
|
+
try { onFailure(message, { afterReady }); } catch { /* diagnostics are best effort */ }
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function onError(error) {
|
|
77
|
+
fail(`restart helper failed to start: ${error?.message ?? String(error)}`);
|
|
78
|
+
}
|
|
79
|
+
function onExit(code, signal) {
|
|
80
|
+
fail(`restart helper exited before handoff${describeExit(code, signal)}`);
|
|
81
|
+
}
|
|
82
|
+
function onMessage(message) {
|
|
83
|
+
if (phase !== "handshake" || message?.type !== RESTART_HELPER_READY_TYPE) return;
|
|
84
|
+
if (message.protocol !== RESTART_HELPER_PROTOCOL_VERSION || message.awaitExitPid !== awaitExitPid) {
|
|
85
|
+
fail(
|
|
86
|
+
`restart helper protocol mismatch (expected v${RESTART_HELPER_PROTOCOL_VERSION} for pid ${awaitExitPid})`,
|
|
87
|
+
{ terminate: true },
|
|
88
|
+
);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
phase = "stability";
|
|
92
|
+
clearTimer();
|
|
93
|
+
// A successful helper is blocked in --await-exit until this Host leaves.
|
|
94
|
+
// Observe it briefly so a parse/startup failure cannot masquerade as an
|
|
95
|
+
// accepted handoff merely because the IPC message won a scheduling race.
|
|
96
|
+
timer = setTimeout(() => {
|
|
97
|
+
if (phase !== "stability") return;
|
|
98
|
+
phase = "accepted";
|
|
99
|
+
try {
|
|
100
|
+
if (child.connected === true) child.disconnect();
|
|
101
|
+
} catch { /* the ready message already proved the channel worked */ }
|
|
102
|
+
try { child.unref(); } catch { /* ChildProcess-compatible fakes may omit it */ }
|
|
103
|
+
settleReady({ ok: true });
|
|
104
|
+
// Preserve the old implementation's one-second response-drain window.
|
|
105
|
+
// The RPC layer does not expose a response-flushed callback. The exit
|
|
106
|
+
// listener remains active: if the helper dies here, the timer is
|
|
107
|
+
// cancelled and the old Host stays.
|
|
108
|
+
timer = setTimeout(() => {
|
|
109
|
+
if (phase !== "accepted") return;
|
|
110
|
+
phase = "committed";
|
|
111
|
+
timer = undefined;
|
|
112
|
+
removeListeners();
|
|
113
|
+
try {
|
|
114
|
+
onHostExit();
|
|
115
|
+
} catch (error) {
|
|
116
|
+
phase = "failed";
|
|
117
|
+
try { onFailure(`could not exit old Host: ${error?.message ?? String(error)}`, { afterReady: true }); } catch { /* best effort */ }
|
|
118
|
+
}
|
|
119
|
+
}, responseDelayMs);
|
|
120
|
+
}, stabilityMs);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
child.on("message", onMessage);
|
|
124
|
+
child.once("error", onError);
|
|
125
|
+
child.once("exit", onExit);
|
|
126
|
+
timer = setTimeout(() => {
|
|
127
|
+
fail(`restart helper did not acknowledge protocol v${RESTART_HELPER_PROTOCOL_VERSION} within ${handshakeTimeoutMs}ms`, { terminate: true });
|
|
128
|
+
}, handshakeTimeoutMs);
|
|
129
|
+
|
|
130
|
+
const dispose = () => {
|
|
131
|
+
if (phase === "failed" || phase === "disposed" || phase === "committed") return;
|
|
132
|
+
phase = "disposed";
|
|
133
|
+
clearTimer();
|
|
134
|
+
removeListeners();
|
|
135
|
+
terminateChild();
|
|
136
|
+
settleReady({ ok: false, error: "restart handoff cancelled because the plugin unloaded" });
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
ready,
|
|
141
|
+
dispose,
|
|
142
|
+
state: () => phase,
|
|
143
|
+
};
|
|
144
|
+
}
|