@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/README.md +4 -3
- package/package.json +1 -1
- package/src/cli.js +924 -65
- package/src/client.js +24 -2
- package/src/index.js +593 -30
- package/src/restart-protocol.js +258 -0
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 {
|
|
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
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
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
|
-
|
|
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 = {
|