@1e0zj/dsh-plugin-mall 0.4.6 → 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;
@@ -1084,14 +1219,13 @@ export function createJobTracker({ producerFactory } = {}) {
1084
1219
  const prune = () => {
1085
1220
  const now = Date.now();
1086
1221
  for (const [id, record] of records) {
1087
- const terminal = record.status !== "running";
1088
- if (terminal && record.finishedAt !== undefined && now - record.finishedAt > 3600000) records.delete(id);
1222
+ if (record.finishedAt !== undefined && now - record.finishedAt > 3600000) records.delete(id);
1089
1223
  }
1090
1224
  if (records.size > 20) {
1091
1225
  const ordered = [...records.entries()].sort((a, b) => a[1].startedAt - b[1].startedAt);
1092
1226
  for (const [id, record] of ordered) {
1093
1227
  if (records.size <= 20) break;
1094
- if (record.status !== "running") records.delete(id);
1228
+ if (record.finishedAt !== undefined) records.delete(id);
1095
1229
  }
1096
1230
  }
1097
1231
  };
@@ -1314,6 +1448,9 @@ export function createJobTracker({ producerFactory } = {}) {
1314
1448
  }
1315
1449
  }
1316
1450
  if (typeof record.producer?.cancel === "function") {
1451
+ if (record.finishedAt === undefined && record.status === "running") {
1452
+ record.status = "stopping";
1453
+ }
1317
1454
  record.producer.cancel();
1318
1455
  }
1319
1456
  if (record.approvalToken) {
@@ -1330,6 +1467,9 @@ export function createJobTracker({ producerFactory } = {}) {
1330
1467
  if (record.surface === "browser" && (record.session === "" || record.session !== session)) {
1331
1468
  return false;
1332
1469
  }
1470
+ // Dismiss hides settled history. Live work must go through cancel and
1471
+ // remain observable until its producer reports a terminal outcome.
1472
+ if (record.finishedAt === undefined) return false;
1333
1473
  // Marked, not deleted: `list` (panel restore after a remount) skips these,
1334
1474
  // so a cleared panel stays cleared across remounts.
1335
1475
  record.dismissed = true;
@@ -2147,6 +2287,15 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker, signa
2147
2287
  } catch (error) {
2148
2288
  return rpcFail(error);
2149
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;
2150
2299
  const plan = resolveRestartLaunchPlan({ profile, config });
2151
2300
  if (!plan.ok) {
2152
2301
  let diagnosticPath;
@@ -2155,6 +2304,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker, signa
2155
2304
  mkdirSync(dirname(diagnosticPath), { recursive: true });
2156
2305
  } catch { /* invalid profile/home: console remains the diagnostic sink */ }
2157
2306
  appendRestartDiagnostic(diagnosticPath, `restart plan rejected: ${plan.error}; old Host remains running`);
2307
+ restartHandoffInFlight = false;
2158
2308
  return rpcFail(new Error(plan.error));
2159
2309
  }
2160
2310
  // Everything the restart prints goes to a file. `stdio: "ignore"` used to
@@ -2173,40 +2323,104 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker, signa
2173
2323
  console.error(`[dsh-plugin-mall] restart log unavailable (${error.message}); continuing without it`);
2174
2324
  logFd = undefined;
2175
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
+
2176
2381
  let child;
2177
- try {
2178
- child = spawn(plan.nodePath, plan.args, {
2179
- shell: false,
2180
- detached: true,
2181
- // fd 3 is an IPC channel used only for the readiness handshake. An
2182
- // old/incompatible CLI either exits on --await-exit or times out; it
2183
- // can never make the current Host leave merely by spawning.
2184
- stdio: ["ignore", logFd ?? "ignore", logFd ?? "ignore", "ipc"],
2185
- cwd: process.cwd(),
2186
- 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
+ },
2187
2416
  });
2188
- } catch (error) {
2189
- if (logFd !== undefined) closeSync(logFd);
2190
- appendRestartDiagnostic(logPath, `restart helper could not be spawned: ${error.message}; old Host remains running`);
2191
- return rpcFail(new Error(`automatic restart helper could not be spawned; the current dsh is still running${logPath ? ` (see ${logPath})` : ""}`));
2192
2417
  }
2193
- if (logFd !== undefined) closeSync(logFd); // the child holds its own duplicates
2194
-
2195
- const handoff = superviseRestartHelper(child, {
2196
- awaitExitPid: plan.awaitExitPid,
2197
- onFailure: (message) => appendRestartDiagnostic(logPath, `${message}; old Host remains running`),
2198
- });
2199
2418
  let disposeHandoffEffect;
2200
2419
  try {
2201
- // Cordis runs this disposer on HMR/config unload. In particular, the
2202
- // process-exit timer can no longer outlive the plugin instance that
2203
- // created it and kill the Host one second later.
2204
- disposeHandoffEffect = ctx.effect(
2205
- () => handoff.dispose,
2206
- "@1e0zj/dsh-plugin-mall: restart handoff",
2207
- );
2420
+ disposeHandoffEffect = registerRestartHandoffEffect(ctx, handoff);
2208
2421
  } catch (error) {
2209
2422
  handoff.dispose();
2423
+ restartHandoffInFlight = false;
2210
2424
  appendRestartDiagnostic(logPath, `restart handoff could not join the plugin lifecycle: ${error.message}; old Host remains running`);
2211
2425
  return rpcFail(new Error("automatic restart was cancelled because the marketplace plugin is unloading; the current dsh is still running"));
2212
2426
  }
@@ -2214,9 +2428,18 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker, signa
2214
2428
  const accepted = await handoff.ready;
2215
2429
  if (!accepted.ok) {
2216
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
+ }
2217
2440
  return rpcFail(new Error(`${accepted.error}; the current dsh is still running${logPath ? ` (see ${logPath})` : ""}`));
2218
2441
  }
2219
- return rpcOk({ restarting: true, handoffAccepted: true, logPath });
2442
+ return rpcOk({ restarting: true, handoffAccepted: true, logPath, mode });
2220
2443
  }
2221
2444
  case "jobCancel": {
2222
2445
  try {
@@ -3012,6 +3235,40 @@ export async function runSelfTests() {
3012
3235
  const snapAfterDismiss = sessionTracker.get(sessionJobId, "session-alpha").snapshot;
3013
3236
  check("dismiss 后 job snapshot 中 approvalToken 为 undefined", snapAfterDismiss.approvalToken === undefined);
3014
3237
 
3238
+ // 运行中任务不能靠 dismiss 从观察面消失;cancel 先进入 stopping,等
3239
+ // producer 真正结算后才成为可清理的历史。
3240
+ {
3241
+ let settleLive;
3242
+ let cancelCalls = 0;
3243
+ const liveProducer = {
3244
+ cancel: () => { cancelCalls++; },
3245
+ done: new Promise((resolvePromise) => { settleLive = resolvePromise; }),
3246
+ readOutput: () => "",
3247
+ };
3248
+ const liveTracker = createJobTracker({ producerFactory: () => liveProducer });
3249
+ const liveJobId = liveTracker.start({
3250
+ profile: "web",
3251
+ spec: "live-pkg",
3252
+ surface: "browser",
3253
+ session: "session-alpha",
3254
+ });
3255
+ check("运行中 job 拒绝 dismiss", liveTracker.dismiss(liveJobId, "session-alpha") === false);
3256
+ check("dismiss 被拒后运行中 job 仍可恢复",
3257
+ liveTracker.list("session-alpha").some((entry) => entry.id === liveJobId));
3258
+ check("cancel 请求把可杀 job 标为 stopping",
3259
+ liveTracker.cancel(liveJobId, "session-alpha") === "requested"
3260
+ && liveTracker.get(liveJobId, "session-alpha").snapshot.status === "stopping"
3261
+ && cancelCalls === 1);
3262
+ check("stopping job 仍拒绝 dismiss", liveTracker.dismiss(liveJobId, "session-alpha") === false);
3263
+ settleLive({ status: "killed", detail: "cancelled" });
3264
+ await new Promise((resolvePromise) => setImmediate(resolvePromise));
3265
+ check("producer 结算后 job 进入 killed",
3266
+ liveTracker.get(liveJobId, "session-alpha").snapshot.status === "killed");
3267
+ check("终态 job 可 dismiss 并不再恢复",
3268
+ liveTracker.dismiss(liveJobId, "session-alpha") === true
3269
+ && liveTracker.list("session-alpha").every((entry) => entry.id !== liveJobId));
3270
+ }
3271
+
3015
3272
  // ── 5b. list(重挂载恢复):日志累积、dismissed 过滤、session 隔离 ────────
3016
3273
  // 安装事务改写 cordis.patch.yml 会让 dsh 重放装配树、市场 UI 整体重挂载,
3017
3274
  // 前端 state 全丢。任务记录在后端活着——list 就是恢复通道:drain 过的
@@ -3095,6 +3352,7 @@ export async function runSelfTests() {
3095
3352
  check("--await-exit 排在 `--` 之前(是 guard 的参数,不是 dsh 的)", plan.args.indexOf("--await-exit") < dashDash);
3096
3353
  check("重启给 dsh 补 --no-open", dshArgs.includes("--no-open") && plan.suppressedBrowserOpen === true);
3097
3354
  check("原始 dsh 参数原样保留", dshArgs.slice(0, 2).join(" ") === "--profile web");
3355
+ check("plan 附带可见模式所需的 dshArgs", plan.dshArgs.join(" ") === dshArgs.join(" "));
3098
3356
 
3099
3357
  // 用户自己已经写了 --no-open 时不重复追加。
3100
3358
  process.argv = [process.execPath, "/x/bin.js", "--profile", "web", "--no-open"];
@@ -3270,6 +3528,350 @@ export async function runSelfTests() {
3270
3528
  );
3271
3529
  }
3272
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
+
3273
3875
  // ── 7. Tracker isolation: producer.done rejection handling ───────────────
3274
3876
  let settledOutcome = null;
3275
3877
  const rejectingProducer = {