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