@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/src/index.js CHANGED
@@ -15,10 +15,12 @@
15
15
 
16
16
  import z from "@deepseek-ai/schemastery";
17
17
  import { defineTool } from "@deepseek-ai/dsh-tools";
18
- import { existsSync, readFileSync, realpathSync, mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
18
+ import { existsSync, readFileSync, realpathSync, mkdirSync, mkdtempSync, writeFileSync, rmSync, openSync, closeSync, writeSync } from "node:fs";
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
- // When this plugin instance loaded — for a host process this is effectively the
292
- // host's start time (the loader mounts plugins at boot). Sent with `jobs` so a
293
- // remounted client can tell "completed, restart still pending" from "completed
294
- // and the restart already happened": a finishedAt older than this value means
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);
@@ -888,7 +889,16 @@ export function resolveRestartLaunchPlan({ profile, config = {}, isWindows }) {
888
889
 
889
890
  const nodePath = process.execPath;
890
891
  const originalDshArgs = process.argv.slice(2);
891
- const args = [cliPath, "guard", "launch", "--profile", name, "--", nodePath, dshEntry, ...originalDshArgs];
892
+ // A restart is always requested from a page that is open and that reconnects
893
+ // to the successor on its own, so dsh's start-up browser handoff would only
894
+ // add a second window onto the one already showing the result. Suppressing it
895
+ // is safe HERE and nowhere else: this route is gated on a live browser
896
+ // session. If the flag is already in the original argv, leave it alone.
897
+ const suppressOpen = !originalDshArgs.includes("--no-open");
898
+ const dshArgs = suppressOpen ? [...originalDshArgs, "--no-open"] : [...originalDshArgs];
899
+ // The outgoing host names itself so `guard launch` can wait for it to be
900
+ // gone before binding the port — see --await-exit in cli.js.
901
+ const args = [cliPath, "guard", "launch", "--profile", name, "--await-exit", String(process.pid), "--", nodePath, dshEntry, ...dshArgs];
892
902
 
893
903
  return {
894
904
  ok: true,
@@ -897,9 +907,40 @@ export function resolveRestartLaunchPlan({ profile, config = {}, isWindows }) {
897
907
  cliPath,
898
908
  dshEntry,
899
909
  profile: name,
910
+ suppressedBrowserOpen: suppressOpen,
911
+ awaitExitPid: process.pid,
900
912
  };
901
913
  }
902
914
 
915
+ /**
916
+ * Where a restart's output goes. The successor is spawned from a process that
917
+ * is about to exit, so it cannot inherit anything that outlives the handoff —
918
+ * and on Windows the console it does get is not attached to its stdio, which
919
+ * is how `[guard] … rolled back` used to vanish. Append to a per-profile file
920
+ * instead, so whatever the restart says survives it.
921
+ */
922
+ export function restartLogPath(profileDir, profile) {
923
+ // <home>/profiles/<name> → <home>/guard/, beside the pending markers.
924
+ return join(dirname(dirname(profileDir)), "guard", `restart-${profile}.log`);
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
+
903
944
  // ── in-process job tracker for browser RPC ───────────────────────────────────
904
945
 
905
946
  let trackerCounter = 0;
@@ -1690,7 +1731,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1690
1731
  // dropping all React state. The task records live here.
1691
1732
  try {
1692
1733
  const session = requireBrowserSession(payload?.session);
1693
- return rpcOk({ jobs: tracker.list(session), hostStartedAt: pluginLoadedAt });
1734
+ return rpcOk({ jobs: tracker.list(session), hostStartedAt: hostProcessStartedAt });
1694
1735
  } catch (error) {
1695
1736
  return rpcFail(error);
1696
1737
  }
@@ -1704,18 +1745,74 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1704
1745
  }
1705
1746
  const plan = resolveRestartLaunchPlan({ profile, config });
1706
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`);
1707
1754
  return rpcFail(new Error(plan.error));
1708
1755
  }
1709
- const child = spawn(plan.nodePath, plan.args, {
1710
- shell: false,
1711
- detached: true,
1712
- stdio: "ignore",
1713
- cwd: process.cwd(),
1714
- windowsHide: true,
1756
+ // Everything the restart prints goes to a file. `stdio: "ignore"` used to
1757
+ // send it to the void — including the one line that explains a rollback —
1758
+ // and the successor inherited those dead handles, which is why the
1759
+ // console Windows allocates for it comes up blank.
1760
+ let logFd;
1761
+ let logPath;
1762
+ try {
1763
+ logPath = restartLogPath(resolveProfileDir(profile), profile);
1764
+ mkdirSync(dirname(logPath), { recursive: true });
1765
+ logFd = openSync(logPath, "a");
1766
+ writeSync(logFd, `\n=== ${new Date().toISOString()} restart requested (pid ${process.pid} → guard launch) ===\n`);
1767
+ } catch (error) {
1768
+ // A log we cannot open is not a reason to refuse the restart.
1769
+ console.error(`[dsh-plugin-mall] restart log unavailable (${error.message}); continuing without it`);
1770
+ logFd = undefined;
1771
+ }
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`),
1715
1794
  });
1716
- child.unref();
1717
- setTimeout(() => process.exit(0), 1000);
1718
- return rpcOk({ restarting: true });
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 });
1719
1816
  }
1720
1817
  case "jobCancel": {
1721
1818
  try {
@@ -2556,6 +2653,195 @@ export async function runSelfTests() {
2556
2653
  const restartDotPlan = resolveRestartLaunchPlan({ profile: "web.", config: { allowRestart: true }, isWindows: true });
2557
2654
  check("尾随点 profile 重启 plan fail-closed", !restartDotPlan.ok && /dot or space/.test(restartDotPlan.error));
2558
2655
 
2656
+ // 重启 argv 的三条硬约束。真实重启只有完整重启 dsh 才验得到,所以 plan 的
2657
+ // 形状必须在这里钉死:少一条就是那三个现象里的一个回来了。
2658
+ {
2659
+ const argvBefore = process.argv;
2660
+ try {
2661
+ process.argv = [process.execPath, "/x/bin.js", "--profile", "web"];
2662
+ const plan = resolveRestartLaunchPlan({ profile: "web", config: { allowRestart: true } });
2663
+ if (plan.ok) {
2664
+ const dashDash = plan.args.indexOf("--");
2665
+ const dshArgs = plan.args.slice(dashDash + 3); // -- node <dshEntry> …
2666
+ check("重启带 --await-exit 且是本进程 pid", plan.args[plan.args.indexOf("--await-exit") + 1] === String(process.pid) && plan.awaitExitPid === process.pid);
2667
+ check("--await-exit 排在 `--` 之前(是 guard 的参数,不是 dsh 的)", plan.args.indexOf("--await-exit") < dashDash);
2668
+ check("重启给 dsh 补 --no-open", dshArgs.includes("--no-open") && plan.suppressedBrowserOpen === true);
2669
+ check("原始 dsh 参数原样保留", dshArgs.slice(0, 2).join(" ") === "--profile web");
2670
+
2671
+ // 用户自己已经写了 --no-open 时不重复追加。
2672
+ process.argv = [process.execPath, "/x/bin.js", "--profile", "web", "--no-open"];
2673
+ const already = resolveRestartLaunchPlan({ profile: "web", config: { allowRestart: true } });
2674
+ const alreadyArgs = already.ok ? already.args.slice(already.args.indexOf("--") + 3) : [];
2675
+ check("已有 --no-open 则不重复追加", already.ok && alreadyArgs.filter((a) => a === "--no-open").length === 1 && already.suppressedBrowserOpen === false);
2676
+ } else {
2677
+ // 裸检出里解析不到官方 dsh 入口,plan 只能 fail——说清楚,别假装验过。
2678
+ console.log(` SKIP 重启 argv fixture(${plan.error})`);
2679
+ }
2680
+ } finally {
2681
+ process.argv = argvBefore;
2682
+ }
2683
+ }
2684
+
2685
+ check("重启日志落在 <home>/guard/ 下", restartLogPath(join("/h", "profiles", "web"), "web").replace(/\\/g, "/").endsWith("/h/guard/restart-web.log"));
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
+
2559
2845
  // ── 7. Tracker isolation: producer.done rejection handling ───────────────
2560
2846
  let settledOutcome = null;
2561
2847
  const rejectingProducer = {
package/src/installer.js CHANGED
@@ -3272,11 +3272,20 @@ async function runTransactionFixtures() {
3272
3272
  const { profileDir, cleanup } = makeTempProfile("remove-success-cleanup", { "pkg-f": "1.0.0" });
3273
3273
  try {
3274
3274
  materializeFakePackage(profileDir, "pkg-f", "1.0.0");
3275
+ // 快照落在 <home>/guard/snapshots/(guard.js 的 snapshotRoot),不在
3276
+ // profile 目录里。第一版查的是 profileDir/.dsh-plugin-guard —— 那个路径
3277
+ // 根本不存在,于是「残留为空」恒成立,这半条断言等于没写。
3278
+ const snapshotDir = join(dirname(dirname(profileDir)), "guard", "snapshots");
3279
+ const listSnapshots = () => (existsSync(snapshotDir) ? readdirSync(snapshotDir) : []);
3280
+ // 只查「结束后为空」还不够:快照压根没被创建也会通过。在 pnpm 在途的
3281
+ // 那一刻取一次,先钉住它确实存在过,再钉住它被提交清理掉。
3282
+ let snapshotsWhileRunning = [];
3275
3283
  const { spawnFn } = scriptedSpawn([{
3276
3284
  code: 0,
3277
3285
  out: "Done\n",
3278
3286
  // pnpm 真正卸掉:清单与目录都拿走,落盘校验才会通过。
3279
3287
  beforeExit: () => {
3288
+ snapshotsWhileRunning = listSnapshots();
3280
3289
  const manifest = JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8"));
3281
3290
  delete manifest.dependencies["pkg-f"];
3282
3291
  writeFileSync(join(profileDir, "package.json"), JSON.stringify(manifest, undefined, 2) + "\n");
@@ -3284,15 +3293,14 @@ async function runTransactionFixtures() {
3284
3293
  },
3285
3294
  }]);
3286
3295
  const outcome = await runRemove({ profile: "p", packageName: "pkg-f", _profileDir: profileDir, _spawn: spawnFn }).done;
3287
- const snapshotsLeft = existsSync(join(profileDir, ".dsh-plugin-guard"))
3288
- ? readdirSync(join(profileDir, ".dsh-plugin-guard")).filter((entry) => entry !== "pending.json")
3289
- : [];
3296
+ const snapshotsLeft = listSnapshots();
3290
3297
  check(
3291
- "卸载成功 → marker 与 snapshot 都被提交清理,不留残留",
3298
+ "卸载成功 → marker 与 snapshot 都被提交清理(且快照确实创建过)",
3292
3299
  outcome.status === "completed"
3300
+ && snapshotsWhileRunning.length === 1
3293
3301
  && !existsSync(pendingMarkerPath(profileDir))
3294
3302
  && snapshotsLeft.length === 0,
3295
- `status=${outcome.status} marker=${existsSync(pendingMarkerPath(profileDir))} snapshots=${snapshotsLeft.join(",")} detail=${JSON.stringify(outcome.detail)}`,
3303
+ `status=${outcome.status} 运行中快照=${snapshotsWhileRunning.join(",")} marker=${existsSync(pendingMarkerPath(profileDir))} 残留快照=${snapshotsLeft.join(",")} detail=${JSON.stringify(outcome.detail)}`,
3296
3304
  );
3297
3305
  } finally {
3298
3306
  cleanup();
@@ -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
+ }