@1e0zj/dsh-plugin-mall 0.3.5 → 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 +6 -6
- package/package.json +1 -1
- package/src/cli.js +206 -10
- package/src/client.js +44 -4
- package/src/guard.js +1576 -76
- package/src/index.js +304 -18
- package/src/installer.js +13 -5
- package/src/restart-protocol.js +144 -0
package/README.md
CHANGED
|
@@ -11,12 +11,12 @@ Two surfaces: a **Settings → Plugins → Marketplace** tab in the dsh web UI,
|
|
|
11
11
|
Curated lists only show what has been reviewed and merged. This marketplace is open by construction: **any repo tagged `topic:dsh-plugin` is discoverable the moment it is pushed** — no submission, no approval queue. To keep that openness usable:
|
|
12
12
|
|
|
13
13
|
- **Automatic verification** — every search result's `package.json` is fetched (jsDelivr/raw dual-source CDN, no API quota) and checked for the official `dsh.bundle` / `dsh.client` manifest. Verified plugins get a green badge; the default "verified only" view filters out ~73% of topic noise (empty repos and unrelated projects riding the tag).
|
|
14
|
-
- **Browse-time compatibility badges** — each card is also statically scanned against your profile before you click anything: declared conflicts, exclusive groups, loader-id collisions (from the repo's patch file), host-module shadowing, and peer/Node/OS ranges. Cards show 适配 / 有风险 / 冲突 / 适配未知 accordingly — advisory only; the install preflight remains the enforcing gate.
|
|
14
|
+
- **Browse-time compatibility badges** — each card is also statically scanned against your profile before you click anything: declared conflicts, exclusive groups, loader-id collisions (from the repo's patch file), rows the candidate's patch would switch off or reconfigure in the profile you already run, host-module shadowing, and peer/Node/OS ranges. Cards show 适配 / 有风险 / 冲突 / 适配未知 accordingly — advisory only; the install preflight remains the enforcing gate.
|
|
15
15
|
- **Anti-squatting** — an install prefers the npm tarball only when the registry entry's `repository` URL points back to the same GitHub repo; anything else falls back to the explicit `github:` spec.
|
|
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
|
-
- **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 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).
|
|
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 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
|
|
|
@@ -149,12 +149,12 @@ pnpm --dir <profile> add "github:omdsh-dev/dsh-at-file" --ignore-scripts
|
|
|
149
149
|
与策展列表不同:**任何打上 `topic:dsh-plugin` 的仓库推送后立即可被发现**——无需投稿、无需审批。为保证开放性可用,做了这些事:
|
|
150
150
|
|
|
151
151
|
- **自动验证**:逐仓库拉取 `package.json`(jsDelivr/raw 双源 CDN,不占 API 配额),按官方 `dsh.bundle` / `dsh.client` 声明打徽章;默认"只看已验证"视图过滤约 73% 的话题噪音
|
|
152
|
-
- **浏览期适配徽章**:点安装之前,每张卡片就已对照你的 profile 做过一次静态扫描——声明冲突、独占组、loader-id
|
|
152
|
+
- **浏览期适配徽章**:点安装之前,每张卡片就已对照你的 profile 做过一次静态扫描——声明冲突、独占组、loader-id 冲突(取自仓库补丁文件)、候选包的补丁会停用或改写你当前 profile 里的哪些行、宿主模块遮蔽、peer/Node/OS 范围;卡片上直接显示 适配 / 有风险 / 冲突 / 适配未知。徽章只是提示,真正的拦截闸门仍是安装预检
|
|
153
153
|
- **防抢注**:仅当 npm registry 条目的 `repository` 指回同一 GitHub 仓库时才用 npm 安装,否则回退 `github:` 源
|
|
154
154
|
- **npm 优先安装**:registry tarball 比整仓库下载更小且带完整性校验;查询用的 registry 跟随 pnpm 实际安装源(profile `.npmrc` → `pnpm config get registry` → npmjs),换了镜像也不会退化成整仓库克隆
|
|
155
155
|
- **更新管理**:已装插件与 registry `latest` 比对,逐个一键更新
|
|
156
|
-
- **冲突防护**:每次安装先跑隔离预检——候选包在一次性目录里以禁用脚本的方式装好后,对照 live profile 扫描 loader-id 冲突、重复挂载、宿主模块遮蔽和版本/OS/peer
|
|
157
|
-
- **工程韧性**:限流熔断、GitHub 5xx/超时退避重试(504 瞬时故障不再直达用户)、GitHub 1000 条搜索上限优雅处理、pnpm 缺失时 `corepack` 自愈、一键重启 dsh(仅 loopback,可 `allowRestart: false`
|
|
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` 关闭)。旧 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
|
|
@@ -637,6 +656,42 @@ function resolveWindowsCommand(command) {
|
|
|
637
656
|
return command;
|
|
638
657
|
}
|
|
639
658
|
|
|
659
|
+
/** How long `--await-exit` waits for the outgoing host before giving up. */
|
|
660
|
+
const AWAIT_EXIT_TIMEOUT_MS = 30000;
|
|
661
|
+
/** Settle time after the pid is gone, before the successor binds the port. */
|
|
662
|
+
const PORT_SETTLE_MS = 400;
|
|
663
|
+
|
|
664
|
+
const delay = (ms) => new Promise((resolvePromise) => { setTimeout(resolvePromise, ms); });
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Poll until `pid` is gone, or the timeout elapses.
|
|
668
|
+
*
|
|
669
|
+
* `kill(pid, 0)` is the portable liveness probe: it delivers no signal and
|
|
670
|
+
* throws ESRCH once the process is gone. EPERM means it exists under another
|
|
671
|
+
* owner — still alive, so keep waiting rather than racing it.
|
|
672
|
+
*
|
|
673
|
+
* A pid is only meaningful because the caller is the process that spawned us
|
|
674
|
+
* and named itself. It can still be recycled in principle; the bounded wait
|
|
675
|
+
* and the refusal on timeout keep that from turning into a hang.
|
|
676
|
+
*
|
|
677
|
+
* @returns true when the process is gone, false on timeout.
|
|
678
|
+
*/
|
|
679
|
+
async function waitForProcessExit(pid, timeoutMs, pollMs = 100) {
|
|
680
|
+
const target = Number(pid);
|
|
681
|
+
if (!Number.isInteger(target) || target <= 0) return true; // nothing to wait for
|
|
682
|
+
const deadline = Date.now() + timeoutMs;
|
|
683
|
+
for (;;) {
|
|
684
|
+
try {
|
|
685
|
+
process.kill(target, 0);
|
|
686
|
+
} catch (error) {
|
|
687
|
+
if (error?.code === "ESRCH") return true;
|
|
688
|
+
if (error?.code !== "EPERM") return true; // unprobeable: do not block the restart on it
|
|
689
|
+
}
|
|
690
|
+
if (Date.now() >= deadline) return false;
|
|
691
|
+
await delay(pollMs);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
640
695
|
/**
|
|
641
696
|
* Spawn the command after `--` with inherited stdio. POSIX uses shell:false so
|
|
642
697
|
* the argv reaches execvp untouched. On Windows a bare name is first resolved
|
|
@@ -644,8 +699,25 @@ function resolveWindowsCommand(command) {
|
|
|
644
699
|
* so they go through %ComSpec% with every token strictly quoted; .exe/.com and
|
|
645
700
|
* explicit paths spawn directly with shell:false.
|
|
646
701
|
*/
|
|
702
|
+
/**
|
|
703
|
+
* Whether to stop Windows conjuring a console for the child.
|
|
704
|
+
*
|
|
705
|
+
* Inherit the console we have; never create one we do not. Run from a terminal
|
|
706
|
+
* our stdio IS that terminal, the child attaches to it, and hiding would cost
|
|
707
|
+
* the interactive session its Ctrl+C. Run from the detached restart our stdio
|
|
708
|
+
* is the log file and we hold no console at all, so the child would be handed
|
|
709
|
+
* a brand-new window — blank, since its output goes to the log — which is the
|
|
710
|
+
* stray console a restart used to leave on screen. Every subprocess this
|
|
711
|
+
* package spawns already passes `windowsHide` (installer.js, six call sites),
|
|
712
|
+
* so nothing downstream depends on inheriting a visible console.
|
|
713
|
+
*/
|
|
714
|
+
function hideChildConsole() {
|
|
715
|
+
return process.platform === "win32" && process.stdout.isTTY !== true;
|
|
716
|
+
}
|
|
717
|
+
|
|
647
718
|
function spawnCommand(command, args) {
|
|
648
719
|
const resolved = process.platform === "win32" ? resolveWindowsCommand(command) : command;
|
|
720
|
+
const windowsHide = hideChildConsole();
|
|
649
721
|
if (process.platform === "win32" && /\.(?:cmd|bat)$/i.test(resolved)) {
|
|
650
722
|
const comspec = process.env.ComSpec ?? "cmd.exe";
|
|
651
723
|
const line = [resolved, ...args].map(quoteCmdArg).join(" ");
|
|
@@ -654,9 +726,9 @@ function spawnCommand(command, args) {
|
|
|
654
726
|
// windowsVerbatimArguments passes the line to CreateProcess exactly as
|
|
655
727
|
// built — otherwise libuv would re-quote it for CommandLineToArgvW and the
|
|
656
728
|
// escaped quotes would break cmd's /s stripping.
|
|
657
|
-
return spawn(comspec, ["/d", "/s", "/c", `"${line}"`], { shell: false, stdio: "inherit", env: process.env, windowsVerbatimArguments: true });
|
|
729
|
+
return spawn(comspec, ["/d", "/s", "/c", `"${line}"`], { shell: false, stdio: "inherit", env: process.env, windowsVerbatimArguments: true, windowsHide });
|
|
658
730
|
}
|
|
659
|
-
return spawn(resolved, args, { shell: false, stdio: "inherit", env: process.env });
|
|
731
|
+
return spawn(resolved, args, { shell: false, stdio: "inherit", env: process.env, windowsHide });
|
|
660
732
|
}
|
|
661
733
|
|
|
662
734
|
/** Forward SIGINT/SIGTERM to the child where the platform delivers them to us. */
|
|
@@ -831,10 +903,38 @@ function markerLooksValid(marker, profileDir, home) {
|
|
|
831
903
|
* A corrupt or legacy (pre-v2) marker, or a failed static-recovery step, fails
|
|
832
904
|
* closed: the command is NOT launched and no unvalidated path is deleted.
|
|
833
905
|
*/
|
|
834
|
-
async function cmdLaunch({
|
|
906
|
+
async function cmdLaunch({
|
|
907
|
+
profile,
|
|
908
|
+
home,
|
|
909
|
+
graceMs,
|
|
910
|
+
commandArgv,
|
|
911
|
+
awaitExitPid,
|
|
912
|
+
_waitForExit = waitForProcessExit,
|
|
913
|
+
_onAwaitExit = announceRestartHelperReady,
|
|
914
|
+
}) {
|
|
835
915
|
const profileDir = profileDirOf(home, profile);
|
|
836
916
|
const grace = graceMs ?? DEFAULT_GRACE_MS;
|
|
837
917
|
const [command, ...args] = commandArgv;
|
|
918
|
+
|
|
919
|
+
// A restart hands us the pid of the host that is on its way out. Starting
|
|
920
|
+
// the successor while it still holds the listening port is not a race worth
|
|
921
|
+
// taking: the bind fails, the probation below reads that as "the pending
|
|
922
|
+
// install crashed dsh", and it rolls back an install that was fine. Wait for
|
|
923
|
+
// the process to be gone, then let the port settle.
|
|
924
|
+
//
|
|
925
|
+
// Refuse rather than start anyway if it outlives the timeout. Starting is
|
|
926
|
+
// the outcome that costs the user their install; not starting leaves the old
|
|
927
|
+
// host running and the marker pending, which the next launch resolves.
|
|
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);
|
|
933
|
+
if (!await _waitForExit(awaitExitPid, AWAIT_EXIT_TIMEOUT_MS)) {
|
|
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)`);
|
|
935
|
+
}
|
|
936
|
+
await delay(PORT_SETTLE_MS);
|
|
937
|
+
}
|
|
838
938
|
// Mirrors guard.js pendingPath(): <home>/guard/pending-<profile>.json.
|
|
839
939
|
const markerPath = join(home, "guard", `pending-${profile}.json`);
|
|
840
940
|
|
|
@@ -915,7 +1015,7 @@ Usage:
|
|
|
915
1015
|
node src/cli.js guard list [--home <dir>]
|
|
916
1016
|
node src/cli.js guard add <spec> --profile <name> [--home <dir>] [--accept-warnings]
|
|
917
1017
|
node src/cli.js guard remove <package> --profile <name> [--home <dir>]
|
|
918
|
-
node src/cli.js guard launch --profile <name> [--home <dir>] [--grace-ms <ms>] -- <command> [args...]
|
|
1018
|
+
node src/cli.js guard launch --profile <name> [--home <dir>] [--grace-ms <ms>] [--await-exit <pid>] -- <command> [args...]
|
|
919
1019
|
node src/cli.js guard self-test
|
|
920
1020
|
|
|
921
1021
|
Commands:
|
|
@@ -956,7 +1056,7 @@ function parseArgs(argv) {
|
|
|
956
1056
|
if (args[0] === "guard") args.shift(); // `node cli.js guard recover` / `node cli.js recover`
|
|
957
1057
|
let command = args.shift() ?? "help";
|
|
958
1058
|
if (command === "--help" || command === "-h") command = "help"; // `cli.js --help`
|
|
959
|
-
const opts = { home: undefined, profile: undefined, graceMs: undefined, acceptWarnings: false, positionals: [], commandArgv: [] };
|
|
1059
|
+
const opts = { home: undefined, profile: undefined, graceMs: undefined, awaitExit: undefined, acceptWarnings: false, positionals: [], commandArgv: [] };
|
|
960
1060
|
for (let index = 0; index < args.length; index++) {
|
|
961
1061
|
const arg = args[index];
|
|
962
1062
|
if (arg === "--") { opts.commandArgv = args.slice(index + 1); break; } // launch: the wrapped command, verbatim
|
|
@@ -968,6 +1068,8 @@ function parseArgs(argv) {
|
|
|
968
1068
|
if (arg.startsWith("--profile=")) { opts.profile = arg.slice("--profile=".length); continue; }
|
|
969
1069
|
if (arg === "--grace-ms") { opts.graceMs = args[++index]; continue; }
|
|
970
1070
|
if (arg.startsWith("--grace-ms=")) { opts.graceMs = arg.slice("--grace-ms=".length); continue; }
|
|
1071
|
+
if (arg === "--await-exit") { opts.awaitExit = args[++index]; continue; }
|
|
1072
|
+
if (arg.startsWith("--await-exit=")) { opts.awaitExit = arg.slice("--await-exit=".length); continue; }
|
|
971
1073
|
if (arg === "--all") { opts.all = true; continue; }
|
|
972
1074
|
if (arg.startsWith("-")) throw new Error(`unknown option ${JSON.stringify(arg)}`);
|
|
973
1075
|
opts.positionals.push(arg);
|
|
@@ -1038,7 +1140,12 @@ async function main(argv) {
|
|
|
1038
1140
|
graceMs = Number(parsed.graceMs);
|
|
1039
1141
|
if (!Number.isFinite(graceMs) || graceMs < 0) throw usageError(`--grace-ms must be a non-negative number, got ${JSON.stringify(parsed.graceMs)}`);
|
|
1040
1142
|
}
|
|
1041
|
-
|
|
1143
|
+
let awaitExitPid;
|
|
1144
|
+
if (parsed.awaitExit !== undefined) {
|
|
1145
|
+
awaitExitPid = Number(parsed.awaitExit);
|
|
1146
|
+
if (!Number.isInteger(awaitExitPid) || awaitExitPid <= 0) throw usageError(`--await-exit must be a positive process id, got ${JSON.stringify(parsed.awaitExit)}`);
|
|
1147
|
+
}
|
|
1148
|
+
process.exitCode = await cmdLaunch({ profile: parsed.profile, home, graceMs, commandArgv: parsed.commandArgv, awaitExitPid });
|
|
1042
1149
|
return;
|
|
1043
1150
|
}
|
|
1044
1151
|
case "self-test": {
|
|
@@ -1101,6 +1208,93 @@ async function selfTest() {
|
|
|
1101
1208
|
if (p2.graceMs !== "0" || p2.commandArgv[0] !== "dsh.cmd" || p2.commandArgv.length !== 2) throw new Error("parseArgs launch =fixture failed");
|
|
1102
1209
|
const p3 = parseArgs(["launch", "--profile", "web", "--", "--weird-but-verbatim"]);
|
|
1103
1210
|
if (p3.commandArgv[0] !== "--weird-but-verbatim") throw new Error("parseArgs should pass post-`--` args through verbatim");
|
|
1211
|
+
const p4 = parseArgs(["launch", "--profile", "web", "--await-exit", "4321", "--", "dsh"]);
|
|
1212
|
+
if (p4.awaitExit !== "4321") throw new Error("parseArgs --await-exit fixture failed");
|
|
1213
|
+
const p5 = parseArgs(["launch", "--profile=web", "--await-exit=99", "--", "dsh"]);
|
|
1214
|
+
if (p5.awaitExit !== "99") throw new Error("parseArgs --await-exit= fixture failed");
|
|
1215
|
+
// A pid after `--` belongs to the wrapped command, not to us.
|
|
1216
|
+
const p6 = parseArgs(["launch", "--profile", "web", "--", "dsh", "--await-exit", "7"]);
|
|
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
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// `--await-exit`: the successor must not start while the outgoing host is
|
|
1233
|
+
// still holding the port. A pid that never goes away is refused outright —
|
|
1234
|
+
// starting anyway is what costs the user their install, because the failed
|
|
1235
|
+
// bind reads as "the pending plugin crashed dsh" and rolls it back.
|
|
1236
|
+
{
|
|
1237
|
+
const p = join(root, "profiles", "await-exit");
|
|
1238
|
+
mkdirSync(p, { recursive: true });
|
|
1239
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: {} }));
|
|
1240
|
+
writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
|
|
1241
|
+
const home = root;
|
|
1242
|
+
|
|
1243
|
+
let launched = false;
|
|
1244
|
+
const neverExits = async () => false;
|
|
1245
|
+
let announcedPid;
|
|
1246
|
+
let refused = false;
|
|
1247
|
+
try {
|
|
1248
|
+
await cmdLaunch({
|
|
1249
|
+
profile: "await-exit",
|
|
1250
|
+
home,
|
|
1251
|
+
commandArgv: [process.execPath, "-e", "launched=1"],
|
|
1252
|
+
awaitExitPid: 999999,
|
|
1253
|
+
_waitForExit: neverExits,
|
|
1254
|
+
_onAwaitExit: async (pid) => { announcedPid = pid; },
|
|
1255
|
+
});
|
|
1256
|
+
} catch (error) {
|
|
1257
|
+
refused = /still running after/.test(error.message);
|
|
1258
|
+
}
|
|
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");
|
|
1261
|
+
|
|
1262
|
+
// …and a pid that does go away lets the command through.
|
|
1263
|
+
const exitsPromptly = async () => true;
|
|
1264
|
+
const code = await cmdLaunch({
|
|
1265
|
+
profile: "await-exit",
|
|
1266
|
+
home,
|
|
1267
|
+
commandArgv: [process.execPath, "-e", "process.exit(0)"],
|
|
1268
|
+
awaitExitPid: 999999,
|
|
1269
|
+
_waitForExit: exitsPromptly,
|
|
1270
|
+
});
|
|
1271
|
+
launched = code === 0;
|
|
1272
|
+
if (!launched) throw new Error("once the outgoing pid is gone the command must run");
|
|
1273
|
+
|
|
1274
|
+
// waitForProcessExit itself: this process is alive, a pid that cannot
|
|
1275
|
+
// exist is gone, and a nonsense pid is not something to wait on.
|
|
1276
|
+
if (await waitForProcessExit(process.pid, 120, 20)) throw new Error("waitForProcessExit must not report a live process as gone");
|
|
1277
|
+
if (!await waitForProcessExit(0x7ffffff0, 500, 20)) throw new Error("waitForProcessExit must report an absent pid as gone");
|
|
1278
|
+
if (!await waitForProcessExit(undefined, 500, 20)) throw new Error("waitForProcessExit must not block on a missing pid");
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// hideChildConsole: inherit the console we have, never create one we do
|
|
1282
|
+
// not. Getting this backwards is visible either way — a stray blank window
|
|
1283
|
+
// after every restart, or an interactive `guard launch` that has lost its
|
|
1284
|
+
// Ctrl+C. Windows-only by construction; asserted on both platforms so the
|
|
1285
|
+
// Linux CI still pins the POSIX half.
|
|
1286
|
+
{
|
|
1287
|
+
const realIsTty = process.stdout.isTTY;
|
|
1288
|
+
try {
|
|
1289
|
+
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
|
|
1290
|
+
if (hideChildConsole()) throw new Error("a child of an interactive terminal must inherit its console, not be hidden from it");
|
|
1291
|
+
Object.defineProperty(process.stdout, "isTTY", { value: undefined, configurable: true });
|
|
1292
|
+
if (hideChildConsole() !== (process.platform === "win32")) {
|
|
1293
|
+
throw new Error("with no console of our own the child must not be given a new window on Windows (and the flag is meaningless elsewhere)");
|
|
1294
|
+
}
|
|
1295
|
+
} finally {
|
|
1296
|
+
Object.defineProperty(process.stdout, "isTTY", { value: realIsTty, configurable: true });
|
|
1297
|
+
}
|
|
1104
1298
|
}
|
|
1105
1299
|
|
|
1106
1300
|
// quoteCmdArg: strict MSVCRT/CommandLineToArgvW quoting; cmd metacharacters
|
|
@@ -1333,9 +1527,11 @@ async function selfTest() {
|
|
|
1333
1527
|
if (!refused || !rolledBack) throw new Error("cmdRemove partial dsh reconcile must roll back despite generic validation passing");
|
|
1334
1528
|
}
|
|
1335
1529
|
|
|
1336
|
-
// Startup must apply the
|
|
1337
|
-
//
|
|
1338
|
-
//
|
|
1530
|
+
// Startup must apply the remove-completion check before probation, so a
|
|
1531
|
+
// long-running app cannot commit a crash-partial remove once the grace
|
|
1532
|
+
// period elapses. The leftover bundle entry fails generic validation too
|
|
1533
|
+
// (a layer without a dsh.bundle manifest stops dsh at startup); the
|
|
1534
|
+
// remove-specific check is what names the actual cause.
|
|
1339
1535
|
{
|
|
1340
1536
|
const launchHome = join(root, "remove-launch-home");
|
|
1341
1537
|
const launchProfile = join(launchHome, "profiles", "web");
|
|
@@ -1353,7 +1549,7 @@ async function selfTest() {
|
|
|
1353
1549
|
dependencies: {},
|
|
1354
1550
|
dsh: { profile: { bundles: ["victim"] } },
|
|
1355
1551
|
}));
|
|
1356
|
-
if (
|
|
1552
|
+
if (validateInstalledProfile(launchProfile).ok) throw new Error("launch partial-remove fixture must fail validation before probation");
|
|
1357
1553
|
const exitCode = await cmdLaunch({
|
|
1358
1554
|
profile: "web",
|
|
1359
1555
|
home: launchHome,
|
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];
|