@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 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 冲突(取自仓库补丁文件)、宿主模块遮蔽、peer/Node/OS 范围;卡片上直接显示 适配 / 有风险 / 冲突 / 适配未知。徽章只是提示,真正的拦截闸门仍是安装预检
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 范围;硬冲突直接拦截,警告需显式确认。安装前给 profile 的承重文件拍快照、失败即回滚;pending 安装在下次启动时自动了结,**不挑启动方式**:本插件加载时就跑恢复,而能加载本身就证明 dsh 已经组装好 profile、活到了这一步。经 `guard launch` 启动则多一层观察期——插件启动几秒后才崩的情况也能回滚并原样重启一次(见下方「启动保护」)。
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1e0zj/dsh-plugin-mall",
3
- "version": "0.3.5",
3
+ "version": "0.4.1",
4
4
  "description": "dsh 插件市场:搜索 GitHub dsh-plugin 话题下的插件仓库,一键安装到本地 dsh profile(agent 工具 + 设置页插件市场 tab)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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({ profile, home, graceMs, commandArgv }) {
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
- process.exitCode = await cmdLaunch({ profile: parsed.profile, home, graceMs, commandArgv: parsed.commandArgv });
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 same remove-completion check before probation;
1337
- // otherwise a long-running app would commit a crash-partial remove after
1338
- // the grace period merely because generic validation says "loadable".
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 (!validateInstalledProfile(launchProfile).ok) throw new Error("launch partial-remove fixture must reproduce generic false-safe validation");
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
- clearInterval(ping);
924
+ clearRestartPing();
898
925
  setRestarting(false);
899
926
  setError("2 分钟内未检测到 dsh 重启完成,请手动检查 dsh 状态后刷新页面。");
900
927
  return;
901
928
  }
902
- rpc.call("/market", "installed", {}).then(function () {
903
- clearInterval(ping);
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, rpc]);
951
+ }, [call, clearRestartPing, hostStartedAt]);
912
952
 
913
953
  var doUninstall = useCallback(function (name) {
914
954
  delete approvalTokensRef.current[name];