@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/README.md +4 -3
- package/package.json +1 -1
- package/src/cli.js +924 -65
- package/src/client.js +278 -112
- package/src/index.js +635 -33
- 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;
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
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
|
-
|
|
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 = {
|