@1e0zj/dsh-plugin-mall 0.4.7 → 0.4.12

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
@@ -16,7 +16,7 @@
16
16
  import z from "@deepseek-ai/schemastery";
17
17
  import { valid as validExactVersion, maxSatisfying } from "semver";
18
18
  import { defineTool } from "@deepseek-ai/dsh-tools";
19
- import { existsSync, readFileSync, realpathSync, mkdirSync, mkdtempSync, writeFileSync, rmSync, openSync, closeSync, writeSync } from "node:fs";
19
+ import { existsSync, readFileSync, readdirSync, realpathSync, mkdirSync, mkdtempSync, utimesSync, writeFileSync, rmSync, openSync, closeSync, writeSync } from "node:fs";
20
20
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
21
21
  import { spawn } from "node:child_process";
22
22
  import { createHash, randomBytes } from "node:crypto";
@@ -29,7 +29,10 @@ import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
29
29
  import { repoInfo, searchPlugins, verifyPlugins, cachedRepoManifest, fetchRawFile, preferNpmSpec, npmPackageInfo, npmPackageVersions, npmNameOf, compareVersions, assertSafeToInstall, mapLimit, NETWORK_CONCURRENCY } from "./github.js";
30
30
  import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, assertSafeSpec, resolveRegistry, serializeCanonicalProof, persistPluginDisabled } from "./installer.js";
31
31
  import { preflightInstall, inspectRemoteCandidate, recoverProfile, describeRollbackRebuild, isAbortError } from "./guard.js";
32
- import { createRestartHelperReadyMessage, RESTART_HELPER_READY_TYPE, RESTART_RESPONSE_DRAIN_MS, superviseRestartHelper } from "./restart-protocol.js";
32
+ import {
33
+ createRestartHelperReadyMessage, RESTART_HELPER_READY_TYPE, RESTART_RESPONSE_DRAIN_MS, superviseRestartHelper,
34
+ RESTART_PLAN_TYPE, RESTART_PLAN_VERSION, quoteCmdArg, readRestartHelperReadyFile, superviseRestartHelperFile, validateRestartPlanPayload, writeRestartHelperReadyFile,
35
+ } from "./restart-protocol.js";
33
36
 
34
37
  export const name = "@1e0zj/dsh-plugin-mall";
35
38
  // `loader` 用来读装配树、并对单个 entry 做热开关(entry.update)。读法照抄
@@ -1040,6 +1043,7 @@ export function resolveRestartLaunchPlan({ profile, config = {}, isWindows }) {
1040
1043
  args,
1041
1044
  cliPath,
1042
1045
  dshEntry,
1046
+ dshArgs,
1043
1047
  profile: name,
1044
1048
  suppressedBrowserOpen: suppressOpen,
1045
1049
  awaitExitPid: process.pid,
@@ -1075,6 +1079,137 @@ function appendRestartDiagnostic(logPath, message) {
1075
1079
  }
1076
1080
  }
1077
1081
 
1082
+ // ── visible-console restart (Windows, interactive terminal) ──────────────────
1083
+
1084
+ // One handoff at a time, process-wide: two concurrent restart requests (two
1085
+ // tabs, or the dialog racing the panel button) would spawn two guards that
1086
+ // both wait for this Host and then both start successors — a port collision
1087
+ // that probation would misread as "the pending install crashed dsh" and roll
1088
+ // back. Reset only on the failure paths; success exits the process.
1089
+ let restartHandoffInFlight = false;
1090
+
1091
+ /**
1092
+ * The restart goes visible only on an interactive Windows console. Two
1093
+ * spellings of "interactive": the original terminal (stdout is a TTY), or a
1094
+ * dsh that was itself launched by the tee'd visible guard — its stdout is
1095
+ * the tee's PIPE, so the guard marks the chain with
1096
+ * DSH_PLUGIN_MALL_VISIBLE_CONSOLE and the TTY signal survives restarts.
1097
+ */
1098
+ export function wantsVisibleConsoleRestart() {
1099
+ if (process.platform !== "win32") return false;
1100
+ if (process.stdout.isTTY === true) return true;
1101
+ return process.env.DSH_PLUGIN_MALL_VISIBLE_CONSOLE === "1";
1102
+ }
1103
+
1104
+ /**
1105
+ * Best-effort sweep of a previous request's plan/ready leftovers in
1106
+ * <home>/guard. Correctness never depends on it: the file names are
1107
+ * per-request unique, so stale files are inert clutter — EXCEPT cancel
1108
+ * sentinels, which are NEVER swept, however old: a paused/suspended guard
1109
+ * has no visible lifetime ceiling, and deleting a sentinel its guard has
1110
+ * not consumed yet is how a retry resurrects a cancelled guard beside its
1111
+ * own one. A sentinel dies only when its guard consumes it; the price is a
1112
+ * few tiny nonce files left behind when a guard never wakes — cheap next
1113
+ * to two successors colliding on the listening port.
1114
+ */
1115
+ function sweepStaleRestartHandoffs(guardDir, profile) {
1116
+ let names;
1117
+ try {
1118
+ names = readdirSync(guardDir);
1119
+ } catch {
1120
+ return;
1121
+ }
1122
+ for (const name of names) {
1123
+ if (name.endsWith(".cancel")) continue;
1124
+ if (name.startsWith(`restart-plan-${profile}-`) || name.startsWith(`restart-ready-${profile}-`)) {
1125
+ try { rmSync(join(guardDir, name), { force: true }); } catch { /* inert residue */ }
1126
+ }
1127
+ }
1128
+ }
1129
+
1130
+ /**
1131
+ * Write the launch plan the visible guard will consume (`--plan-file`). The
1132
+ * wrapped dsh argv travels as JSON — never through the cmd command line, where
1133
+ * spaces, quotes and metacharacters would be re-parsed. The plan does NOT
1134
+ * carry the home dir: the guard inherits DSH_HOME through cmd → start and
1135
+ * resolves it itself, one less path to trust.
1136
+ */
1137
+ function writeVisibleRestartPlan({ plan, logPath }) {
1138
+ if (typeof logPath !== "string" || logPath.length === 0) {
1139
+ return { ok: false, error: "restart log path unavailable" };
1140
+ }
1141
+ try {
1142
+ const guardDir = dirname(logPath);
1143
+ mkdirSync(guardDir, { recursive: true });
1144
+ sweepStaleRestartHandoffs(guardDir, plan.profile);
1145
+ const nonce = randomBytes(6).toString("hex");
1146
+ const suffix = `${plan.profile}-${plan.awaitExitPid}-${nonce}`;
1147
+ const planPath = join(guardDir, `restart-plan-${suffix}.json`);
1148
+ const readyFile = join(guardDir, `restart-ready-${suffix}.json`);
1149
+ writeFileSync(planPath, `${JSON.stringify({
1150
+ version: RESTART_PLAN_VERSION,
1151
+ type: RESTART_PLAN_TYPE,
1152
+ profile: plan.profile,
1153
+ awaitExitPid: plan.awaitExitPid,
1154
+ logPath,
1155
+ readyFile,
1156
+ cwd: process.cwd(),
1157
+ command: plan.nodePath,
1158
+ args: [plan.dshEntry, ...plan.dshArgs],
1159
+ }, null, 2)}\n`);
1160
+ return { ok: true, planPath, readyFile };
1161
+ } catch (error) {
1162
+ return { ok: false, error: error.message };
1163
+ }
1164
+ }
1165
+
1166
+ /**
1167
+ * Join the handoff to the plugin lifecycle. Cordis runs the callback
1168
+ * IMMEDIATELY and registers its RETURN VALUE as the disposer — so the
1169
+ * callback must merely build the disposer, never perform the disposal
1170
+ * (a block body here disposed the handoff at registration time and broke
1171
+ * every restart; pinned by fixture). The disposer also releases the
1172
+ * in-flight latch: a disposed handoff is over, the old Host stays.
1173
+ */
1174
+ function registerRestartHandoffEffect(ctx, handoff) {
1175
+ return ctx.effect(
1176
+ () => () => { handoff.dispose(); restartHandoffInFlight = false; },
1177
+ "@1e0zj/dsh-plugin-mall: restart handoff",
1178
+ );
1179
+ }
1180
+
1181
+ /**
1182
+ * Launch the visible guard: a new console window via `cmd /d /s /c start`
1183
+ * (never /b — the window is the feature). cmd returns immediately; the guard
1184
+ * runs as a grandchild with no stdio or IPC link back, which is why the
1185
+ * handoff moves to the ready file. Every token on the line is strictly
1186
+ * quoted — a path cmd cannot digest is a construction failure, and the
1187
+ * caller falls back to the background path rather than mangling the command.
1188
+ */
1189
+ function spawnVisibleRestartGuard({ plan, planPath, _spawn = spawn }) {
1190
+ let child;
1191
+ try {
1192
+ const line = [
1193
+ "start",
1194
+ quoteCmdArg(`dsh guard - ${plan.profile}`),
1195
+ [plan.nodePath, plan.cliPath, "guard", "launch", "--plan-file", planPath].map(quoteCmdArg).join(" "),
1196
+ ].join(" ");
1197
+ child = _spawn(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `"${line}"`], {
1198
+ shell: false,
1199
+ detached: true,
1200
+ stdio: "ignore",
1201
+ cwd: process.cwd(),
1202
+ env: process.env,
1203
+ windowsVerbatimArguments: true,
1204
+ windowsHide: false,
1205
+ });
1206
+ } catch (error) {
1207
+ return { ok: false, error: error.message };
1208
+ }
1209
+ try { child.unref(); } catch { /* ChildProcess-compatible fakes may omit it */ }
1210
+ return { ok: true, child };
1211
+ }
1212
+
1078
1213
  // ── in-process job tracker for browser RPC ───────────────────────────────────
1079
1214
 
1080
1215
  let trackerCounter = 0;
@@ -2152,6 +2287,15 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker, signa
2152
2287
  } catch (error) {
2153
2288
  return rpcFail(error);
2154
2289
  }
2290
+ // One handoff at a time, process-wide, checked BEFORE anything else
2291
+ // opens files or resolves plans: two concurrent requests would spawn
2292
+ // two guards that both wait for this Host and then both start a
2293
+ // successor — a port collision probation would misread as a bad
2294
+ // install. Reset only on the failure paths; success exits the process.
2295
+ if (restartHandoffInFlight) {
2296
+ return rpcFail(new Error("a restart handoff is already in progress — the page reconnects on its own once it completes"));
2297
+ }
2298
+ restartHandoffInFlight = true;
2155
2299
  const plan = resolveRestartLaunchPlan({ profile, config });
2156
2300
  if (!plan.ok) {
2157
2301
  let diagnosticPath;
@@ -2160,6 +2304,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker, signa
2160
2304
  mkdirSync(dirname(diagnosticPath), { recursive: true });
2161
2305
  } catch { /* invalid profile/home: console remains the diagnostic sink */ }
2162
2306
  appendRestartDiagnostic(diagnosticPath, `restart plan rejected: ${plan.error}; old Host remains running`);
2307
+ restartHandoffInFlight = false;
2163
2308
  return rpcFail(new Error(plan.error));
2164
2309
  }
2165
2310
  // Everything the restart prints goes to a file. `stdio: "ignore"` used to
@@ -2178,40 +2323,104 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker, signa
2178
2323
  console.error(`[dsh-plugin-mall] restart log unavailable (${error.message}); continuing without it`);
2179
2324
  logFd = undefined;
2180
2325
  }
2326
+ let handoff;
2327
+ let mode = "background";
2328
+ let visiblePlan;
2329
+ if (wantsVisibleConsoleRestart()) {
2330
+ // The interactive path: a visible console window runs the guard, its
2331
+ // output is teed to the window and this log, and the handoff travels
2332
+ // through the ready file (cmd /c start leaves no IPC link). Any
2333
+ // CONSTRUCTION failure here falls back to the background path — a
2334
+ // half-built window must never block the restart itself. A handoff
2335
+ // failure after the spawn does NOT retry: re-spawning while the first
2336
+ // guard might just be slow would create two successors.
2337
+ if (logFd !== undefined) {
2338
+ closeSync(logFd); // the visible guard opens the log itself (tee)
2339
+ logFd = undefined;
2340
+ }
2341
+ visiblePlan = writeVisibleRestartPlan({ plan, logPath });
2342
+ if (visiblePlan.ok) {
2343
+ const spawned = spawnVisibleRestartGuard({ plan, planPath: visiblePlan.planPath });
2344
+ if (spawned.ok) {
2345
+ mode = "visible";
2346
+ handoff = superviseRestartHelperFile({
2347
+ readyFile: visiblePlan.readyFile,
2348
+ awaitExitPid: plan.awaitExitPid,
2349
+ onFailure: (message, meta) => {
2350
+ appendRestartDiagnostic(logPath, `${message}; old Host remains running`);
2351
+ // The RPC already answered ok when a post-ready death happens:
2352
+ // the old Host correctly stays, but this handoff is over —
2353
+ // unlock restarts, or one dead helper bricks the button
2354
+ // until a manual restart.
2355
+ if (meta?.afterReady === true) restartHandoffInFlight = false;
2356
+ },
2357
+ });
2358
+ // start makes cmd return immediately and its exit code is
2359
+ // unreliable (a failed start can still exit 0), so this fast-fail
2360
+ // is a bonus, not the mechanism — the handshake timeout is what
2361
+ // actually bounds the wait.
2362
+ spawned.child.once("exit", (code) => {
2363
+ if (code !== 0 && code !== null && handoff.state() === "handshake") {
2364
+ handoff.failFast(`cmd exited with code ${code} before the guard announced itself`);
2365
+ }
2366
+ });
2367
+ spawned.child.once("error", () => {
2368
+ if (handoff.state() === "handshake") {
2369
+ handoff.failFast("cmd failed before the guard announced itself");
2370
+ }
2371
+ });
2372
+ } else {
2373
+ appendRestartDiagnostic(logPath, `visible console unavailable (${spawned.error}); continuing on the background path`);
2374
+ try { rmSync(visiblePlan.planPath, { force: true }); } catch { /* inert residue */ }
2375
+ }
2376
+ } else {
2377
+ appendRestartDiagnostic(logPath, `visible console unavailable (${visiblePlan.error}); continuing on the background path`);
2378
+ }
2379
+ }
2380
+
2181
2381
  let child;
2182
- try {
2183
- child = spawn(plan.nodePath, plan.args, {
2184
- shell: false,
2185
- detached: true,
2186
- // fd 3 is an IPC channel used only for the readiness handshake. An
2187
- // old/incompatible CLI either exits on --await-exit or times out; it
2188
- // can never make the current Host leave merely by spawning.
2189
- stdio: ["ignore", logFd ?? "ignore", logFd ?? "ignore", "ipc"],
2190
- cwd: process.cwd(),
2191
- windowsHide: true,
2382
+ if (mode === "background") {
2383
+ // A visible→background fallback closed the fd above; reopen it, or
2384
+ // the background helper's output (including any rollback line) would
2385
+ // go nowhere at all.
2386
+ if (logFd === undefined && logPath !== undefined) {
2387
+ try { logFd = openSync(logPath, "a"); } catch { logFd = undefined; }
2388
+ }
2389
+ try {
2390
+ child = spawn(plan.nodePath, plan.args, {
2391
+ shell: false,
2392
+ detached: true,
2393
+ // fd 3 is an IPC channel used only for the readiness handshake. An
2394
+ // old/incompatible CLI either exits on --await-exit or times out; it
2395
+ // can never make the current Host leave merely by spawning.
2396
+ stdio: ["ignore", logFd ?? "ignore", logFd ?? "ignore", "ipc"],
2397
+ cwd: process.cwd(),
2398
+ windowsHide: true,
2399
+ });
2400
+ } catch (error) {
2401
+ if (logFd !== undefined) closeSync(logFd);
2402
+ restartHandoffInFlight = false;
2403
+ appendRestartDiagnostic(logPath, `restart helper could not be spawned: ${error.message}; old Host remains running`);
2404
+ return rpcFail(new Error(`automatic restart helper could not be spawned; the current dsh is still running${logPath ? ` (see ${logPath})` : ""}`));
2405
+ }
2406
+ if (logFd !== undefined) closeSync(logFd); // the child holds its own duplicates
2407
+
2408
+ handoff = superviseRestartHelper(child, {
2409
+ awaitExitPid: plan.awaitExitPid,
2410
+ onFailure: (message, meta) => {
2411
+ appendRestartDiagnostic(logPath, `${message}; old Host remains running`);
2412
+ // Same unlock as the visible path: a helper dying after the RPC
2413
+ // answered must not leave the one-restart-at-a-time latch stuck.
2414
+ if (meta?.afterReady === true) restartHandoffInFlight = false;
2415
+ },
2192
2416
  });
2193
- } catch (error) {
2194
- if (logFd !== undefined) closeSync(logFd);
2195
- appendRestartDiagnostic(logPath, `restart helper could not be spawned: ${error.message}; old Host remains running`);
2196
- return rpcFail(new Error(`automatic restart helper could not be spawned; the current dsh is still running${logPath ? ` (see ${logPath})` : ""}`));
2197
2417
  }
2198
- if (logFd !== undefined) closeSync(logFd); // the child holds its own duplicates
2199
-
2200
- const handoff = superviseRestartHelper(child, {
2201
- awaitExitPid: plan.awaitExitPid,
2202
- onFailure: (message) => appendRestartDiagnostic(logPath, `${message}; old Host remains running`),
2203
- });
2204
2418
  let disposeHandoffEffect;
2205
2419
  try {
2206
- // Cordis runs this disposer on HMR/config unload. In particular, the
2207
- // process-exit timer can no longer outlive the plugin instance that
2208
- // created it and kill the Host one second later.
2209
- disposeHandoffEffect = ctx.effect(
2210
- () => handoff.dispose,
2211
- "@1e0zj/dsh-plugin-mall: restart handoff",
2212
- );
2420
+ disposeHandoffEffect = registerRestartHandoffEffect(ctx, handoff);
2213
2421
  } catch (error) {
2214
2422
  handoff.dispose();
2423
+ restartHandoffInFlight = false;
2215
2424
  appendRestartDiagnostic(logPath, `restart handoff could not join the plugin lifecycle: ${error.message}; old Host remains running`);
2216
2425
  return rpcFail(new Error("automatic restart was cancelled because the marketplace plugin is unloading; the current dsh is still running"));
2217
2426
  }
@@ -2219,9 +2428,18 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker, signa
2219
2428
  const accepted = await handoff.ready;
2220
2429
  if (!accepted.ok) {
2221
2430
  await disposeHandoffEffect();
2431
+ restartHandoffInFlight = false;
2432
+ if (mode === "visible" && visiblePlan?.ok) {
2433
+ // Cancel sentinel for a guard that may merely be SLOW: we cannot
2434
+ // kill it (cmd /c start hid its pid from us), so before the user
2435
+ // retries, leave a mark the guard checks after its await-exit wait
2436
+ // ends — a second successor next to the retry's one is exactly the
2437
+ // port collision probation misreads as a bad install.
2438
+ try { writeFileSync(`${visiblePlan.readyFile}.cancel`, `cancelled ${new Date().toISOString()}: ${accepted.error}\n`); } catch { /* best effort */ }
2439
+ }
2222
2440
  return rpcFail(new Error(`${accepted.error}; the current dsh is still running${logPath ? ` (see ${logPath})` : ""}`));
2223
2441
  }
2224
- return rpcOk({ restarting: true, handoffAccepted: true, logPath });
2442
+ return rpcOk({ restarting: true, handoffAccepted: true, logPath, mode });
2225
2443
  }
2226
2444
  case "jobCancel": {
2227
2445
  try {
@@ -3134,6 +3352,7 @@ export async function runSelfTests() {
3134
3352
  check("--await-exit 排在 `--` 之前(是 guard 的参数,不是 dsh 的)", plan.args.indexOf("--await-exit") < dashDash);
3135
3353
  check("重启给 dsh 补 --no-open", dshArgs.includes("--no-open") && plan.suppressedBrowserOpen === true);
3136
3354
  check("原始 dsh 参数原样保留", dshArgs.slice(0, 2).join(" ") === "--profile web");
3355
+ check("plan 附带可见模式所需的 dshArgs", plan.dshArgs.join(" ") === dshArgs.join(" "));
3137
3356
 
3138
3357
  // 用户自己已经写了 --no-open 时不重复追加。
3139
3358
  process.argv = [process.execPath, "/x/bin.js", "--profile", "web", "--no-open"];
@@ -3309,6 +3528,350 @@ export async function runSelfTests() {
3309
3528
  );
3310
3529
  }
3311
3530
 
3531
+ // File-channel handoff (the visible-console path): the ready file replaces
3532
+ // the IPC message while the phase machine must stay equivalent — including
3533
+ // the accepted-phase liveness watch that keeps a post-RPC death from
3534
+ // costing the old Host its exit.
3535
+ {
3536
+ const fileRoot = mkdtempSync(join(tmpdir(), "dsh-mall-restart-file-"));
3537
+ try {
3538
+ const awaitPid = 4711;
3539
+ const pause = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
3540
+ const basePlan = {
3541
+ version: 1,
3542
+ type: RESTART_PLAN_TYPE,
3543
+ profile: "web",
3544
+ awaitExitPid: awaitPid,
3545
+ logPath: "C:/l/restart-web.log",
3546
+ readyFile: join(fileRoot, "r1.json"),
3547
+ cwd: "C:/w",
3548
+ command: "C:/node/node.exe",
3549
+ args: ["C:/dsh/index.js", "web", "--no-open"],
3550
+ };
3551
+ check("plan payload 校验通过", validateRestartPlanPayload(basePlan).ok === true);
3552
+ for (const [override, needle] of [
3553
+ [{ version: 2 }, "version"],
3554
+ [{ type: "someone-elses-plan" }, "type"],
3555
+ [{ args: ["C:/dsh/index.js", 3] }, "args"],
3556
+ [{ awaitExitPid: 0 }, "awaitExitPid"],
3557
+ [{ logPath: "" }, "logPath"],
3558
+ [{ command: 42 }, "command"],
3559
+ ]) {
3560
+ const badResult = validateRestartPlanPayload({ ...basePlan, ...override });
3561
+ check(
3562
+ `plan payload 拒绝坏 ${needle}`,
3563
+ badResult.ok === false && new RegExp(needle).test(badResult.error),
3564
+ );
3565
+ }
3566
+ check("plan payload 拒绝非对象", validateRestartPlanPayload("nope").ok === false);
3567
+ check(
3568
+ "readRestartHelperReadyFile 对缺失/坏文件读作未就绪",
3569
+ readRestartHelperReadyFile(join(fileRoot, "missing.json")) === undefined
3570
+ && (() => {
3571
+ writeFileSync(join(fileRoot, "garbage.json"), "{ half-written");
3572
+ return readRestartHelperReadyFile(join(fileRoot, "garbage.json")) === undefined;
3573
+ })(),
3574
+ );
3575
+
3576
+ const mkFileSupervise = (name, overrides = {}) => {
3577
+ let hostExits = 0;
3578
+ const failures = [];
3579
+ const kills = [];
3580
+ const handoff = superviseRestartHelperFile({
3581
+ readyFile: join(fileRoot, name),
3582
+ awaitExitPid: awaitPid,
3583
+ handshakeTimeoutMs: 80,
3584
+ stabilityMs: 60,
3585
+ responseDelayMs: 120,
3586
+ pollMs: 2,
3587
+ probe: () => true,
3588
+ kill: (pid) => { kills.push(pid); },
3589
+ onHostExit: () => { hostExits++; },
3590
+ onFailure: (message, meta) => { failures.push({ message, meta }); },
3591
+ ...overrides,
3592
+ });
3593
+ return { handoff, kills, failures, hostExits: () => hostExits };
3594
+ };
3595
+
3596
+ const good = mkFileSupervise("good.json", { stabilityMs: 8, responseDelayMs: 8 });
3597
+ writeRestartHelperReadyFile(join(fileRoot, "good.json"), { awaitExitPid: awaitPid, guardPid: 31337 });
3598
+ const goodReady = await good.handoff.ready;
3599
+ await pause(60);
3600
+ check(
3601
+ "文件握手成功 → ready 文件被父删除、旧 Host 恰退出一次、不 kill",
3602
+ goodReady.ok === true && good.hostExits() === 1
3603
+ && !existsSync(join(fileRoot, "good.json"))
3604
+ && good.kills.length === 0 && good.failures.length === 0,
3605
+ );
3606
+
3607
+ const mismatch = mkFileSupervise("mismatch.json");
3608
+ writeFileSync(join(fileRoot, "mismatch.json"), JSON.stringify({
3609
+ type: RESTART_HELPER_READY_TYPE, protocol: 99, awaitExitPid: awaitPid, guardPid: 31338,
3610
+ }));
3611
+ const mismatchReady = await mismatch.handoff.ready;
3612
+ check(
3613
+ "文件握手协议不匹配 → kill 该 helper 恰一次、旧 Host 不退",
3614
+ mismatchReady.ok === false && /protocol mismatch/.test(mismatchReady.error)
3615
+ && mismatch.kills.length === 1 && mismatch.hostExits() === 0
3616
+ && !existsSync(join(fileRoot, "mismatch.json")),
3617
+ );
3618
+
3619
+ const wrongPid = mkFileSupervise("wrongpid.json");
3620
+ writeRestartHelperReadyFile(join(fileRoot, "wrongpid.json"), { awaitExitPid: 9999, guardPid: 31339 });
3621
+ const wrongPidReady = await wrongPid.handoff.ready;
3622
+ check(
3623
+ "ready 文件 awaitExitPid 不匹配 → fail closed 且 kill 一次",
3624
+ wrongPidReady.ok === false && /protocol mismatch/.test(wrongPidReady.error)
3625
+ && wrongPid.kills.length === 1 && wrongPid.hostExits() === 0,
3626
+ );
3627
+
3628
+ const garbage = mkFileSupervise("garbage2.json", { handshakeTimeoutMs: 30 });
3629
+ writeFileSync(join(fileRoot, "garbage2.json"), "{ still not json");
3630
+ const garbageReady = await garbage.handoff.ready;
3631
+ check(
3632
+ "半截/无关 ready 文件 → 容忍到握手超时、绝不 kill 未知 pid",
3633
+ garbageReady.ok === false && /did not write/.test(garbageReady.error)
3634
+ && garbage.kills.length === 0 && garbage.hostExits() === 0,
3635
+ );
3636
+
3637
+ let midAlive = true;
3638
+ const mid = mkFileSupervise("mid.json", { probe: (pid) => midAlive });
3639
+ writeRestartHelperReadyFile(join(fileRoot, "mid.json"), { awaitExitPid: awaitPid, guardPid: 31340 });
3640
+ await pause(20);
3641
+ midAlive = false;
3642
+ const midReady = await mid.handoff.ready;
3643
+ check(
3644
+ "稳定窗口内 helper 消失 → 取消旧 Host 退出",
3645
+ midReady.ok === false && /stability window/.test(midReady.error) && mid.hostExits() === 0,
3646
+ );
3647
+
3648
+ let lateAlive = true;
3649
+ const late = mkFileSupervise("late.json", { probe: (pid) => lateAlive, responseDelayMs: 150 });
3650
+ writeRestartHelperReadyFile(join(fileRoot, "late.json"), { awaitExitPid: awaitPid, guardPid: 31341 });
3651
+ const lateReady = await late.handoff.ready; // resolves at accepted
3652
+ lateAlive = false;
3653
+ await pause(60);
3654
+ check(
3655
+ "RPC 应答后 helper 死亡 → 仍取消旧 Host 退出(accepted 持续探活)",
3656
+ lateReady.ok === true && late.hostExits() === 0
3657
+ && late.failures.length === 1 && late.failures[0].meta.afterReady === true,
3658
+ );
3659
+
3660
+ const disposed = mkFileSupervise("disp.json", { responseDelayMs: 150 });
3661
+ writeRestartHelperReadyFile(join(fileRoot, "disp.json"), { awaitExitPid: awaitPid, guardPid: 31342 });
3662
+ const disposedReady = await disposed.handoff.ready;
3663
+ disposed.handoff.dispose(); // mirrors the disposer returned from ctx.effect()
3664
+ await pause(200);
3665
+ check(
3666
+ "插件卸载 dispose → 清理 timer、kill helper、旧 Host 不退",
3667
+ disposedReady.ok === true && disposed.hostExits() === 0
3668
+ && disposed.kills.length === 1 && disposed.handoff.state() === "disposed",
3669
+ );
3670
+
3671
+ const fast = mkFileSupervise("fast.json", { handshakeTimeoutMs: 5000 });
3672
+ fast.handoff.failFast("cmd exited with code 1 before the guard started");
3673
+ const fastReady = await fast.handoff.ready;
3674
+ check(
3675
+ "failFast(cmd 先死)→ 提前失败,不等握手超时",
3676
+ fastReady.ok === false && /cmd exited/.test(fastReady.error) && fast.hostExits() === 0,
3677
+ );
3678
+
3679
+ // Every terminal state (success, failure, dispose) must leave no
3680
+ // polling interval behind: a leaked 100ms timer keeps the Host
3681
+ // process from ever exiting naturally.
3682
+ const countTimers = () => process.getActiveResourcesInfo().filter((entry) => entry === "Timeout").length;
3683
+ const timersBefore = countTimers();
3684
+ const leak = mkFileSupervise("leak.json", { handshakeTimeoutMs: 25, pollMs: 2 });
3685
+ const leakReady = await leak.handoff.ready;
3686
+ await pause(60); // any surviving interval would have ticked by now
3687
+ const leaked = countTimers() - timersBefore;
3688
+ check(
3689
+ "握手终态后不残留轮询 interval(进程可自然退出)",
3690
+ leakReady.ok === false && leaked <= 0,
3691
+ );
3692
+ } finally {
3693
+ rmSync(fileRoot, { recursive: true, force: true });
3694
+ }
3695
+ }
3696
+
3697
+ // Visible-console branch (interactive Windows restart): the plan file
3698
+ // carries the wrapped argv as JSON, the cmd line is built from strictly
3699
+ // quoted fixed tokens only, and construction failures fall back to the
3700
+ // background path. The real window is a manual-verification item; these
3701
+ // pin everything up to the spawn.
3702
+ {
3703
+ const visibleRoot = mkdtempSync(join(tmpdir(), "dsh-mall-restart-visible-"));
3704
+ try {
3705
+ const realIsTty = process.stdout.isTTY;
3706
+ const realVisibleEnv = process.env.DSH_PLUGIN_MALL_VISIBLE_CONSOLE;
3707
+ if (process.platform === "win32") {
3708
+ try {
3709
+ Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
3710
+ check("win32 + 交互 stdout → 走可见控制台", wantsVisibleConsoleRestart() === true);
3711
+ Object.defineProperty(process.stdout, "isTTY", { value: undefined, configurable: true });
3712
+ check("win32 + 无 TTY → 保持后台路径", wantsVisibleConsoleRestart() === false);
3713
+ // The tee'd guard pipes the successor's stdout: the TTY signal is
3714
+ // gone, the env flag must carry the chain across restarts.
3715
+ process.env.DSH_PLUGIN_MALL_VISIBLE_CONSOLE = "1";
3716
+ check("win32 + tee 链(stdout 为管道)→ 后续重启保持可见", wantsVisibleConsoleRestart() === true);
3717
+ delete process.env.DSH_PLUGIN_MALL_VISIBLE_CONSOLE;
3718
+ } finally {
3719
+ Object.defineProperty(process.stdout, "isTTY", { value: realIsTty, configurable: true });
3720
+ if (realVisibleEnv === undefined) delete process.env.DSH_PLUGIN_MALL_VISIBLE_CONSOLE;
3721
+ else process.env.DSH_PLUGIN_MALL_VISIBLE_CONSOLE = realVisibleEnv;
3722
+ }
3723
+ } else {
3724
+ check("非 Windows 平台永不走可见控制台", wantsVisibleConsoleRestart() === false);
3725
+ }
3726
+
3727
+ const guardDir = join(visibleRoot, "guard");
3728
+ const logPath = join(guardDir, "restart-web.log");
3729
+ mkdirSync(guardDir, { recursive: true });
3730
+ // stale leftovers from a previous request are swept, not mistaken —
3731
+ // but cancel sentinels are NEVER swept, however old: a paused guard
3732
+ // has no lifetime ceiling, and deleting an unconsumed sentinel is
3733
+ // how a retry resurrects a cancelled guard beside its own one.
3734
+ writeFileSync(join(guardDir, "restart-plan-web-111-old.json"), "{}");
3735
+ writeFileSync(join(guardDir, "restart-ready-web-111-old.json"), "{}");
3736
+ const freshCancel = join(guardDir, "restart-ready-web-112-fresh.json.cancel");
3737
+ const staleCancel = join(guardDir, "restart-ready-web-113-stale.json.cancel");
3738
+ writeFileSync(freshCancel, "just written by a failed handoff\n");
3739
+ writeFileSync(staleCancel, "old sentinel, guard long gone\n");
3740
+ const staleTime = new Date(Date.now() - 24 * 60 * 60 * 1000);
3741
+ utimesSync(staleCancel, staleTime, staleTime);
3742
+ const launchPlan = {
3743
+ ok: true,
3744
+ nodePath: process.execPath,
3745
+ cliPath: join(visibleRoot, "cli.js"),
3746
+ dshEntry: join(visibleRoot, "dsh-entry.js"),
3747
+ dshArgs: ["--profile", "web", "--no-open"],
3748
+ profile: "web",
3749
+ awaitExitPid: 4242,
3750
+ };
3751
+ const written = writeVisibleRestartPlan({ plan: launchPlan, logPath });
3752
+ check("可见重启计划写出成功且清扫旧残留(cancel 哨兵永不清扫)", written.ok === true
3753
+ && !existsSync(join(guardDir, "restart-plan-web-111-old.json"))
3754
+ && !existsSync(join(guardDir, "restart-ready-web-111-old.json"))
3755
+ && existsSync(freshCancel) === true
3756
+ && existsSync(staleCancel) === true);
3757
+ const planPayload = JSON.parse(readFileSync(written.planPath, "utf8"));
3758
+ check(
3759
+ "计划 JSON:wrapped argv 只走 JSON、不走 cmd 行、不带 home",
3760
+ planPayload.type === RESTART_PLAN_TYPE && planPayload.version === RESTART_PLAN_VERSION
3761
+ && planPayload.profile === "web" && planPayload.awaitExitPid === 4242
3762
+ && planPayload.command === process.execPath
3763
+ && planPayload.args.join(" ") === [launchPlan.dshEntry, ...launchPlan.dshArgs].join(" ")
3764
+ && planPayload.logPath === logPath && planPayload.readyFile === written.readyFile
3765
+ && planPayload.home === undefined,
3766
+ );
3767
+
3768
+ let spawned = undefined;
3769
+ const spawnCapture = (command, args, options) => {
3770
+ spawned = { command, args, options };
3771
+ return { unref() {} };
3772
+ };
3773
+ const okSpawn = spawnVisibleRestartGuard({ plan: launchPlan, planPath: written.planPath, _spawn: spawnCapture });
3774
+ const cmdArg = spawned.args[3];
3775
+ check(
3776
+ "cmd 行:/d /s /c verbatim + start 带标题 + 全 token 引用 + 不隐藏 + detached",
3777
+ okSpawn.ok === true
3778
+ && spawned.command === (process.env.ComSpec ?? "cmd.exe")
3779
+ && spawned.args[0] === "/d" && spawned.args[1] === "/s" && spawned.args[2] === "/c"
3780
+ && cmdArg.startsWith('"start "dsh guard - web"')
3781
+ && spawned.options.shell === false && spawned.options.detached === true
3782
+ && spawned.options.windowsVerbatimArguments === true
3783
+ && spawned.options.windowsHide === false
3784
+ && spawned.options.stdio === "ignore",
3785
+ );
3786
+ check(
3787
+ "cmd 行不含任何原始 dsh 参数(只有固定 token 与计划文件路径)",
3788
+ cmdArg.includes("--profile") === false && cmdArg.includes("--no-open") === false
3789
+ && cmdArg.includes("--plan-file"),
3790
+ );
3791
+
3792
+ spawned = undefined; // prove the metacharacter failure never spawns
3793
+ const badPath = spawnVisibleRestartGuard({
3794
+ plan: { ...launchPlan, nodePath: "C:/x&y/node.exe" },
3795
+ planPath: written.planPath,
3796
+ _spawn: spawnCapture,
3797
+ });
3798
+ check("cmd 元字符路径 → 构造失败回退(不 spawn)", badPath.ok === false && spawned === undefined);
3799
+
3800
+ // In-flight guard: a second concurrent request fails fast without
3801
+ // touching spawn — two guards would both await this Host and both
3802
+ // start successors. Checked before plan resolution, so this holds in
3803
+ // a bare checkout (CI) where the plan itself cannot resolve dsh.
3804
+ const realDshHome = process.env.DSH_HOME;
3805
+ const realConsoleError = console.error;
3806
+ restartHandoffInFlight = true;
3807
+ let inFlightResponse;
3808
+ try {
3809
+ process.env.DSH_HOME = visibleRoot;
3810
+ console.error = () => {};
3811
+ inFlightResponse = await rpcDispatch({}, "restart", { profile: "web", session: `sess_${"a".repeat(32)}` }, { defaultProfile: "web", allowRestart: true }, undefined, {});
3812
+ } finally {
3813
+ restartHandoffInFlight = false;
3814
+ console.error = realConsoleError;
3815
+ if (realDshHome === undefined) delete process.env.DSH_HOME;
3816
+ else process.env.DSH_HOME = realDshHome;
3817
+ }
3818
+ check(
3819
+ "已有交接在途 → 第二次请求立即拒绝",
3820
+ inFlightResponse?.ok === false && /already in progress/.test(inFlightResponse.error?.message ?? ""),
3821
+ );
3822
+
3823
+ // ctx.effect runs the callback IMMEDIATELY and registers its return
3824
+ // value as the disposer — a block body that disposes inline (the
3825
+ // third-round review catch) killed every handoff at registration and
3826
+ // broke restarts entirely. Pin the real registration semantics with
3827
+ // a cordis-faithful fake: registering must not dispose, and the
3828
+ // registered disposer must dispose AND release the latch.
3829
+ {
3830
+ const fakeHandoff = { disposeCalls: 0, dispose() { this.disposeCalls += 1; } };
3831
+ const registered = [];
3832
+ const fakeCtx = {
3833
+ effect(callback) {
3834
+ const disposer = callback();
3835
+ registered.push(disposer);
3836
+ return () => disposer();
3837
+ },
3838
+ };
3839
+ restartHandoffInFlight = true;
3840
+ const unregister = registerRestartHandoffEffect(fakeCtx, fakeHandoff);
3841
+ const registrationClean = fakeHandoff.disposeCalls === 0
3842
+ && typeof registered[0] === "function";
3843
+ registered[0]();
3844
+ const disposalWorks = fakeHandoff.disposeCalls === 1 && restartHandoffInFlight === false;
3845
+ restartHandoffInFlight = false;
3846
+ unregister();
3847
+ check(
3848
+ "ctx.effect 注册语义:注册不 dispose、disposer 才 dispose 并解锁",
3849
+ registrationClean && disposalWorks,
3850
+ );
3851
+ }
3852
+ } finally {
3853
+ rmSync(visibleRoot, { recursive: true, force: true });
3854
+ }
3855
+ }
3856
+
3857
+ // On Windows pin the /d /s /c verbatim quoting against the real cmd.exe
3858
+ // (echo, no window): the same shell route the visible restart takes.
3859
+ if (process.platform === "win32") {
3860
+ const echoed = await new Promise((resolvePromise) => {
3861
+ let text = "";
3862
+ const child = spawn(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", '"echo "dsh verbatim check""'], {
3863
+ shell: false,
3864
+ stdio: ["ignore", "pipe", "pipe"],
3865
+ windowsVerbatimArguments: true,
3866
+ windowsHide: true,
3867
+ });
3868
+ child.stdout.on("data", (chunk) => { text += chunk; });
3869
+ child.on("close", () => resolvePromise(text));
3870
+ child.on("error", () => resolvePromise(""));
3871
+ });
3872
+ check("cmd /d /s /c verbatim 引用链路(真 echo:外层引号剥、内层保留)", echoed.trim() === "\"dsh verbatim check\"");
3873
+ }
3874
+
3312
3875
  // ── 7. Tracker isolation: producer.done rejection handling ───────────────
3313
3876
  let settledOutcome = null;
3314
3877
  const rejectingProducer = {