@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/cli.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import { spawn } from "node:child_process";
|
|
28
28
|
import {
|
|
29
|
+
createWriteStream,
|
|
29
30
|
existsSync,
|
|
30
31
|
lstatSync,
|
|
31
32
|
mkdirSync,
|
|
@@ -34,9 +35,12 @@ import {
|
|
|
34
35
|
realpathSync,
|
|
35
36
|
rmSync,
|
|
36
37
|
symlinkSync,
|
|
38
|
+
unlinkSync,
|
|
37
39
|
writeFileSync,
|
|
38
40
|
} from "node:fs";
|
|
39
41
|
import { tmpdir } from "node:os";
|
|
42
|
+
import { EventEmitter } from "node:events";
|
|
43
|
+
import { Writable } from "node:stream";
|
|
40
44
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
41
45
|
import { fileURLToPath } from "node:url";
|
|
42
46
|
import {
|
|
@@ -62,7 +66,7 @@ import {
|
|
|
62
66
|
// github.js imports node builtins only — the host-independence of this CLI is
|
|
63
67
|
// preserved (it must keep working when the dsh host itself is broken).
|
|
64
68
|
import { npmPackageInfo } from "./github.js";
|
|
65
|
-
import { createRestartHelperReadyMessage } from "./restart-protocol.js";
|
|
69
|
+
import { createRestartHelperReadyMessage, quoteCmdArg, RESTART_PLAN_TYPE, validateRestartPlanPayload, writeRestartHelperReadyFile } from "./restart-protocol.js";
|
|
66
70
|
|
|
67
71
|
/**
|
|
68
72
|
* Pin a bare package name to name@latest: pnpm's minimumReleaseAge policy
|
|
@@ -98,6 +102,17 @@ export async function announceRestartHelperReady(awaitExitPid, send = process.se
|
|
|
98
102
|
return true;
|
|
99
103
|
}
|
|
100
104
|
|
|
105
|
+
/**
|
|
106
|
+
* File-channel announcement for the visible-console restart: `cmd /c start`
|
|
107
|
+
* gives this process no IPC link back to the Web Host, so the same handshake
|
|
108
|
+
* message is published as a file instead. Unlike announceRestartHelperReady,
|
|
109
|
+
* a missing channel is impossible here — a failed write rejects and the launch
|
|
110
|
+
* fails closed rather than letting the Web parent time out blind.
|
|
111
|
+
*/
|
|
112
|
+
export async function announceRestartHandoffViaFile(plan, awaitExitPid) {
|
|
113
|
+
await writeRestartHelperReadyFile(plan.readyFile, { awaitExitPid, guardPid: process.pid });
|
|
114
|
+
}
|
|
115
|
+
|
|
101
116
|
// ── small helpers ────────────────────────────────────────────────────────────
|
|
102
117
|
|
|
103
118
|
// Same shell-metacharacter blocklist as installer.assertSafeSpec. The install
|
|
@@ -600,26 +615,9 @@ async function cmdRemove({
|
|
|
600
615
|
|
|
601
616
|
const DEFAULT_GRACE_MS = 10000;
|
|
602
617
|
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
const CMD_METACHAR_RE = /[&|<>^%!\r\n]/;
|
|
607
|
-
|
|
608
|
-
/**
|
|
609
|
-
* Quote one token for a %ComSpec% /d /s /c command line. Follows the MSVCRT /
|
|
610
|
-
* CommandLineToArgvW rules (backslashes before a quote or the closing quote are
|
|
611
|
-
* doubled, quotes become \") and rejects cmd metacharacters instead of trying
|
|
612
|
-
* to escape them. The command after `--` is never concatenated unquoted.
|
|
613
|
-
*/
|
|
614
|
-
function quoteCmdArg(token) {
|
|
615
|
-
const value = String(token ?? "");
|
|
616
|
-
if (value.length === 0) return '""';
|
|
617
|
-
if (CMD_METACHAR_RE.test(value)) {
|
|
618
|
-
throw new Error(`cannot quote safely for cmd.exe (shell metacharacter present): ${JSON.stringify(value)}`);
|
|
619
|
-
}
|
|
620
|
-
const escaped = value.replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\+)$/, "$1$1");
|
|
621
|
-
return `"${escaped}"`;
|
|
622
|
-
}
|
|
618
|
+
// quoteCmdArg/CMD_METACHAR_RE live in restart-protocol.js: the Web plugin needs
|
|
619
|
+
// the same strict quoting to build the `cmd /c start` line for a visible
|
|
620
|
+
// restart, so the rules must never drift between the two call sites.
|
|
623
621
|
|
|
624
622
|
/** Case-insensitive env lookup (Windows env keys are case-insensitive). */
|
|
625
623
|
function envValue(name) {
|
|
@@ -674,9 +672,12 @@ const delay = (ms) => new Promise((resolvePromise) => { setTimeout(resolvePromis
|
|
|
674
672
|
* and named itself. It can still be recycled in principle; the bounded wait
|
|
675
673
|
* and the refusal on timeout keep that from turning into a hang.
|
|
676
674
|
*
|
|
677
|
-
*
|
|
675
|
+
* `shouldAbort` (the Ctrl+C watcher's flag) cuts the wait short: the caller
|
|
676
|
+
* reads it and refuses the launch instead of outwaiting a cancelled restart.
|
|
677
|
+
*
|
|
678
|
+
* @returns true when the process is gone, false on timeout or abort.
|
|
678
679
|
*/
|
|
679
|
-
async function waitForProcessExit(pid, timeoutMs, pollMs = 100) {
|
|
680
|
+
async function waitForProcessExit(pid, timeoutMs, pollMs = 100, shouldAbort = undefined) {
|
|
680
681
|
const target = Number(pid);
|
|
681
682
|
if (!Number.isInteger(target) || target <= 0) return true; // nothing to wait for
|
|
682
683
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -687,6 +688,7 @@ async function waitForProcessExit(pid, timeoutMs, pollMs = 100) {
|
|
|
687
688
|
if (error?.code === "ESRCH") return true;
|
|
688
689
|
if (error?.code !== "EPERM") return true; // unprobeable: do not block the restart on it
|
|
689
690
|
}
|
|
691
|
+
if (shouldAbort !== undefined && shouldAbort()) return false;
|
|
690
692
|
if (Date.now() >= deadline) return false;
|
|
691
693
|
await delay(pollMs);
|
|
692
694
|
}
|
|
@@ -744,6 +746,283 @@ function forwardSignals(child) {
|
|
|
744
746
|
};
|
|
745
747
|
}
|
|
746
748
|
|
|
749
|
+
// ── tee runner (visible-console restart) ─────────────────────────────────────
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* The tee pipes the child's stdout away from the console, so color/TTY
|
|
753
|
+
* detection inside dsh would dim itself. Nudge it back unless the user opted
|
|
754
|
+
* out with NO_COLOR.
|
|
755
|
+
*/
|
|
756
|
+
function teeEnv(base) {
|
|
757
|
+
if (base.NO_COLOR !== undefined) return base;
|
|
758
|
+
return { ...base, FORCE_COLOR: "1" };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* The visible-console spawn: stdout/stderr are piped to the tee runner, and
|
|
763
|
+
* stdin is deliberately NOT the console. A live test showed why: dsh (or a
|
|
764
|
+
* component inside it) calls setRawMode on an inherited TTY stdin, and raw
|
|
765
|
+
* mode clears ENABLE_PROCESSED_INPUT console-wide — from then on a real
|
|
766
|
+
* Ctrl+C never becomes a CTRL_C_EVENT and NOTHING on the console (guard
|
|
767
|
+
* included) can receive it. With stdin ignored, no TTY ever reaches the
|
|
768
|
+
* child, processed input stays on, and Ctrl+C reaches every attached process
|
|
769
|
+
* — which is how the guard's interrupt handling works at all. The wrapped
|
|
770
|
+
* command is a web host; it has no use for window stdin.
|
|
771
|
+
*/
|
|
772
|
+
function spawnTeeCommand(command, args, { cwd }) {
|
|
773
|
+
const resolved = process.platform === "win32" ? resolveWindowsCommand(command) : command;
|
|
774
|
+
// Mark the chain: the wrapped dsh has a PIPE for stdout (the tee), so the
|
|
775
|
+
// "interactive terminal" signal is gone even though it lives in a console
|
|
776
|
+
// window. The env flag lets its own restarts stay visible.
|
|
777
|
+
const env = { ...teeEnv(process.env), DSH_PLUGIN_MALL_VISIBLE_CONSOLE: "1" };
|
|
778
|
+
const stdio = ["ignore", "pipe", "pipe"];
|
|
779
|
+
if (process.platform === "win32" && /\.(?:cmd|bat)$/i.test(resolved)) {
|
|
780
|
+
const comspec = process.env.ComSpec ?? "cmd.exe";
|
|
781
|
+
const line = [resolved, ...args].map(quoteCmdArg).join(" ");
|
|
782
|
+
return spawn(comspec, ["/d", "/s", "/c", `"${line}"`], { shell: false, stdio, cwd, env, windowsVerbatimArguments: true, windowsHide: false });
|
|
783
|
+
}
|
|
784
|
+
return spawn(resolved, args, { shell: false, stdio, cwd, env, windowsHide: false });
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function defaultOpenTeeLog(logPath) {
|
|
788
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
789
|
+
return createWriteStream(logPath, { flags: "a" });
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Tee runner for the visible-console restart: everything the wrapped command
|
|
794
|
+
* prints is mirrored to BOTH this guard's console (the window `cmd /c start`
|
|
795
|
+
* allocated) and the restart log. A log that cannot be opened, or that fails
|
|
796
|
+
* mid-run, only costs the log — the window keeps showing output and the
|
|
797
|
+
* child's exit code is untouched.
|
|
798
|
+
*
|
|
799
|
+
* Backpressure: while EITHER target is saturated BOTH source pipes stay
|
|
800
|
+
* paused (pausing just one would leave the other free to keep filling the
|
|
801
|
+
* saturated target); each target resumes the pair once it drains.
|
|
802
|
+
*/
|
|
803
|
+
function createTeeRunner({ logPath, cwd, _stdout = process.stdout, _openLog = defaultOpenTeeLog }) {
|
|
804
|
+
let logStream;
|
|
805
|
+
let broken = false;
|
|
806
|
+
try {
|
|
807
|
+
logStream = _openLog(logPath);
|
|
808
|
+
} catch (error) {
|
|
809
|
+
broken = true;
|
|
810
|
+
_stdout.write(`[guard] warning: cannot open restart log ${logPath} (${error.message}) — continuing without it\n`);
|
|
811
|
+
}
|
|
812
|
+
let consoleSaturated = false;
|
|
813
|
+
let logSaturated = false;
|
|
814
|
+
let paused = false;
|
|
815
|
+
const activeStreams = new Set();
|
|
816
|
+
const onConsoleDrain = () => { consoleSaturated = false; maybeResume(); };
|
|
817
|
+
const onLogDrain = () => { logSaturated = false; maybeResume(); };
|
|
818
|
+
_stdout.on("drain", onConsoleDrain);
|
|
819
|
+
if (logStream !== undefined) {
|
|
820
|
+
logStream.on("drain", onLogDrain);
|
|
821
|
+
logStream.on("error", (error) => {
|
|
822
|
+
if (broken) return;
|
|
823
|
+
broken = true;
|
|
824
|
+
_stdout.write(`[guard] warning: restart log ${logPath} failed (${error.message}) — continuing without it\n`);
|
|
825
|
+
// A failed stream never drains. Clear the log-side backpressure or the
|
|
826
|
+
// paused source pipes stay paused forever and the wrapped dsh hangs.
|
|
827
|
+
logSaturated = false;
|
|
828
|
+
maybeResume();
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
function maybeResume() {
|
|
832
|
+
if (!paused || consoleSaturated || logSaturated) return;
|
|
833
|
+
paused = false;
|
|
834
|
+
for (const stream of activeStreams) stream.resume();
|
|
835
|
+
}
|
|
836
|
+
function onData(chunk) {
|
|
837
|
+
const consoleOk = _stdout.write(chunk);
|
|
838
|
+
const logOk = broken || logStream === undefined ? true : logStream.write(chunk);
|
|
839
|
+
if (consoleOk && logOk) return;
|
|
840
|
+
if (!consoleOk) consoleSaturated = true;
|
|
841
|
+
if (!logOk) logSaturated = true;
|
|
842
|
+
if (!paused) {
|
|
843
|
+
paused = true;
|
|
844
|
+
for (const stream of activeStreams) stream.pause();
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
let closed = false;
|
|
848
|
+
return {
|
|
849
|
+
get broken() { return broken; },
|
|
850
|
+
spawn(command, args) { return spawnTeeCommand(command, args, { cwd }); },
|
|
851
|
+
attach(child) {
|
|
852
|
+
const streams = [child.stdout, child.stderr].filter((stream) => stream !== null && stream !== undefined);
|
|
853
|
+
for (const stream of streams) {
|
|
854
|
+
activeStreams.add(stream);
|
|
855
|
+
stream.on("data", onData);
|
|
856
|
+
}
|
|
857
|
+
return () => {
|
|
858
|
+
for (const stream of streams) {
|
|
859
|
+
stream.removeListener("data", onData);
|
|
860
|
+
activeStreams.delete(stream);
|
|
861
|
+
}
|
|
862
|
+
maybeResume();
|
|
863
|
+
};
|
|
864
|
+
},
|
|
865
|
+
// tee waits for `close`, not `exit`: the pipes still carry in-flight
|
|
866
|
+
// output after exit, and ending the log before they drain would lose it.
|
|
867
|
+
waitFor(child) {
|
|
868
|
+
return new Promise((resolvePromise) => {
|
|
869
|
+
child.once("error", (error) => resolvePromise({ error }));
|
|
870
|
+
child.once("close", (code, signal) => resolvePromise({ code, signal }));
|
|
871
|
+
});
|
|
872
|
+
},
|
|
873
|
+
// Guard's own [guard] lines share the same two targets. The classic IO
|
|
874
|
+
// path routes by level (stdout/stderr); in a console window both land on
|
|
875
|
+
// the same screen, so the tee keeps a single stream.
|
|
876
|
+
say(text) {
|
|
877
|
+
const line = `${text}\n`;
|
|
878
|
+
_stdout.write(line);
|
|
879
|
+
if (!broken && logStream !== undefined) logStream.write(line);
|
|
880
|
+
},
|
|
881
|
+
async close() {
|
|
882
|
+
if (closed) return;
|
|
883
|
+
closed = true;
|
|
884
|
+
_stdout.removeListener("drain", onConsoleDrain);
|
|
885
|
+
if (logStream === undefined) return;
|
|
886
|
+
logStream.removeListener("drain", onLogDrain);
|
|
887
|
+
await new Promise((resolvePromise) => {
|
|
888
|
+
try {
|
|
889
|
+
logStream.end(() => resolvePromise());
|
|
890
|
+
} catch {
|
|
891
|
+
resolvePromise();
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
},
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** The launch IO every run shares: spawn, attach signals, wait for exit. */
|
|
899
|
+
const classicIO = {
|
|
900
|
+
spawn: spawnCommand,
|
|
901
|
+
attach: (child) => forwardSignals(child),
|
|
902
|
+
waitFor: waitForExit,
|
|
903
|
+
say: (text, level) => { console[level === "error" ? "error" : "log"](text); },
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
// ── console Ctrl handling (visible restart, Windows) ─────────────────────────
|
|
907
|
+
|
|
908
|
+
// NTSTATUS 0xC000013A: how a console process exits when it honored
|
|
909
|
+
// CTRL_C_EVENT. Depending on the libuv version Node reports it as exit code
|
|
910
|
+
// or maps it to SIGINT — both spellings are honored below.
|
|
911
|
+
const STATUS_CONTROL_C_EXIT = 3221225786;
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Tear down the whole process TREE of a wrapped command that ignored its
|
|
915
|
+
* Ctrl+C. TerminateProcess (child.kill) does not cascade: dsh's graceful
|
|
916
|
+
* shutdown can hang on in-flight jobs, and even a killed dsh leaves its
|
|
917
|
+
* grandchildren (pnpm, job subprocesses) as orphans still attached to the
|
|
918
|
+
* console — which is exactly the "window will not close" a user sees after
|
|
919
|
+
* pressing Ctrl+C. taskkill /T /F tears the tree down in one step, the same
|
|
920
|
+
* escalation the official dsh-subprocess-local applies for console-wide
|
|
921
|
+
* teardown. The pid is always our own child's integer pid, never a string.
|
|
922
|
+
*/
|
|
923
|
+
function defaultKillProcessTree(pid) {
|
|
924
|
+
if (process.platform !== "win32") {
|
|
925
|
+
try { process.kill(pid, "SIGKILL"); } catch { /* already gone */ }
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
const killer = spawn(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `taskkill /PID ${pid} /T /F >nul 2>&1`], { shell: false, stdio: "ignore", windowsHide: true });
|
|
929
|
+
killer.once("error", () => {
|
|
930
|
+
try { process.kill(pid); } catch { /* already gone */ }
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Ctrl+C on a console reaches every attached process: the wrapped dsh gets
|
|
936
|
+
* the event directly. This watcher's job is only on the GUARD's side —
|
|
937
|
+
*
|
|
938
|
+
* - keep it alive (a Node process without a SIGINT handler exits at once,
|
|
939
|
+
* orphaning nothing but losing the log flush and the exit-code report);
|
|
940
|
+
* - remember the run was interrupted. On Windows a killed child reports
|
|
941
|
+
* code 1 / no signal, which probation would otherwise read as "the
|
|
942
|
+
* pending install crashed dsh" and roll back a perfectly good install;
|
|
943
|
+
* - escalate: if the child has not quit on its own within forceGraceMs,
|
|
944
|
+
* terminate the whole tree so the listening port is released AND the
|
|
945
|
+
* console window can actually close; a second Ctrl+C escalates at once;
|
|
946
|
+
* - SIGHUP (the window's close button): the OS is about to kill the whole
|
|
947
|
+
* console unconditionally — hold the handler open only so the normal
|
|
948
|
+
* cleanup path (log close) can finish before that lands.
|
|
949
|
+
*/
|
|
950
|
+
function createInterruptWatcher({ forceGraceMs = 5000, _killTree = defaultKillProcessTree } = {}) {
|
|
951
|
+
let interrupted = false;
|
|
952
|
+
let child;
|
|
953
|
+
let forceTimer;
|
|
954
|
+
const abortListeners = new Set();
|
|
955
|
+
const terminate = () => {
|
|
956
|
+
if (child === undefined) return;
|
|
957
|
+
try {
|
|
958
|
+
_killTree(child.pid);
|
|
959
|
+
} catch {
|
|
960
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
961
|
+
}
|
|
962
|
+
};
|
|
963
|
+
const signal = () => {
|
|
964
|
+
for (const listener of abortListeners) {
|
|
965
|
+
try { listener(); } catch { /* best effort */ }
|
|
966
|
+
}
|
|
967
|
+
if (interrupted) {
|
|
968
|
+
terminate();
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
interrupted = true;
|
|
972
|
+
if (child !== undefined) armForceKill();
|
|
973
|
+
};
|
|
974
|
+
function armForceKill() {
|
|
975
|
+
if (forceTimer !== undefined || child === undefined) return;
|
|
976
|
+
forceTimer = setTimeout(terminate, forceGraceMs);
|
|
977
|
+
if (typeof forceTimer.unref === "function") forceTimer.unref();
|
|
978
|
+
}
|
|
979
|
+
const onHup = () => { /* see the doc comment: let cleanup run, OS decides */ };
|
|
980
|
+
process.on("SIGINT", signal);
|
|
981
|
+
process.on("SIGBREAK", signal);
|
|
982
|
+
process.on("SIGHUP", onHup);
|
|
983
|
+
return {
|
|
984
|
+
interrupted: () => interrupted,
|
|
985
|
+
noteChild(nextChild) {
|
|
986
|
+
child = nextChild;
|
|
987
|
+
if (interrupted) armForceKill(); // defensive: a spawn after a cancel
|
|
988
|
+
},
|
|
989
|
+
onAbort(listener) { abortListeners.add(listener); },
|
|
990
|
+
offAbort(listener) { abortListeners.delete(listener); },
|
|
991
|
+
unlisten() {
|
|
992
|
+
process.removeListener("SIGINT", signal);
|
|
993
|
+
process.removeListener("SIGBREAK", signal);
|
|
994
|
+
process.removeListener("SIGHUP", onHup);
|
|
995
|
+
if (forceTimer !== undefined) clearTimeout(forceTimer);
|
|
996
|
+
},
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** Sleep for inspection pauses; any further Ctrl+C (via the watcher) ends it. */
|
|
1001
|
+
function waitForSignalOrTimeout(ms, watcher) {
|
|
1002
|
+
return new Promise((resolvePromise) => {
|
|
1003
|
+
const timer = setTimeout(finish, ms);
|
|
1004
|
+
const onAbort = () => { finish(); };
|
|
1005
|
+
function finish() {
|
|
1006
|
+
clearTimeout(timer);
|
|
1007
|
+
if (watcher !== undefined) watcher.offAbort(onAbort);
|
|
1008
|
+
resolvePromise();
|
|
1009
|
+
}
|
|
1010
|
+
if (watcher !== undefined) watcher.onAbort(onAbort);
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Was the wrapped command interrupted from outside rather than crashed?
|
|
1016
|
+
* POSIX says SIGINT/SIGTERM; Windows says "killed" (code 1, no signal) or
|
|
1017
|
+
* STATUS_CONTROL_C_EXIT — there the watcher's flag is the reliable spelling
|
|
1018
|
+
* and the NTSTATUS is belt-and-braces.
|
|
1019
|
+
*/
|
|
1020
|
+
function isInterruptedOutcome(outcome, watcher) {
|
|
1021
|
+
if (outcome.signal === "SIGINT" || outcome.signal === "SIGTERM") return true;
|
|
1022
|
+
if (outcome.code === STATUS_CONTROL_C_EXIT) return true;
|
|
1023
|
+
return watcher !== undefined && watcher.interrupted();
|
|
1024
|
+
}
|
|
1025
|
+
|
|
747
1026
|
function waitForExit(child) {
|
|
748
1027
|
return new Promise((resolvePromise) => {
|
|
749
1028
|
child.on("error", (error) => resolvePromise({ error }));
|
|
@@ -759,15 +1038,26 @@ function exitCodeOf(result) {
|
|
|
759
1038
|
return 1;
|
|
760
1039
|
}
|
|
761
1040
|
|
|
762
|
-
/**
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
1041
|
+
/**
|
|
1042
|
+
* Run the wrapped command once and resolve with its exit result. Every launch
|
|
1043
|
+
* mode routes through here — the plain terminal, the detached background
|
|
1044
|
+
* restart, and the tee'd visible console — so probation's rollback-and-restart
|
|
1045
|
+
* keeps whatever IO the launch started with (the retried dsh must be teed
|
|
1046
|
+
* exactly like the first one).
|
|
1047
|
+
*/
|
|
1048
|
+
async function runCommand(command, args, io = classicIO) {
|
|
1049
|
+
const child = io.spawn(command, args);
|
|
1050
|
+
const cleanup = io.attach(child);
|
|
1051
|
+
const result = await io.waitFor(child);
|
|
1052
|
+
if (typeof cleanup === "function") await cleanup();
|
|
768
1053
|
return result;
|
|
769
1054
|
}
|
|
770
1055
|
|
|
1056
|
+
/** Run the command once with no probation; resolve with its exit result. */
|
|
1057
|
+
async function runPlain(command, args, io = classicIO) {
|
|
1058
|
+
return runCommand(command, args, io);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
771
1061
|
/**
|
|
772
1062
|
* Commit the pending snapshot once startup probation passes. A commit failure
|
|
773
1063
|
* is a warning, not a launch failure — the process is already running and
|
|
@@ -778,7 +1068,7 @@ async function runPlain(command, args) {
|
|
|
778
1068
|
* Roll it back to the pre-install snapshot instead (a rollback failure keeps
|
|
779
1069
|
* the marker for the next attempt, same as recoverProfile).
|
|
780
1070
|
*/
|
|
781
|
-
function commitLaunchSnapshot(profileDir) {
|
|
1071
|
+
function commitLaunchSnapshot(profileDir, say = (text, level) => { console[level === "error" ? "error" : "log"](text); }) {
|
|
782
1072
|
try {
|
|
783
1073
|
let pending;
|
|
784
1074
|
try {
|
|
@@ -788,13 +1078,13 @@ function commitLaunchSnapshot(profileDir) {
|
|
|
788
1078
|
}
|
|
789
1079
|
if (pending !== undefined && pendingApprovalPaused(pending) !== undefined) {
|
|
790
1080
|
rollbackPendingSnapshot(profileDir);
|
|
791
|
-
|
|
1081
|
+
say(`[guard] startup probation passed, but the install was abandoned at the approval gate — profile rolled back for ${profileDir}`);
|
|
792
1082
|
return;
|
|
793
1083
|
}
|
|
794
1084
|
commitPendingSnapshot(profileDir);
|
|
795
|
-
|
|
1085
|
+
say(`[guard] startup probation passed — pending snapshot committed for ${profileDir}`);
|
|
796
1086
|
} catch (error) {
|
|
797
|
-
|
|
1087
|
+
say(`[guard] warning: could not commit the pending snapshot for ${profileDir}: ${error.message} — the marker stays pending`, "error");
|
|
798
1088
|
}
|
|
799
1089
|
}
|
|
800
1090
|
|
|
@@ -804,10 +1094,10 @@ function commitLaunchSnapshot(profileDir) {
|
|
|
804
1094
|
* means it stayed alive through it (the snapshot is committed at that point)
|
|
805
1095
|
* and the wrapper kept waiting for it.
|
|
806
1096
|
*/
|
|
807
|
-
async function runProbation({ profileDir, command, args, graceMs }) {
|
|
808
|
-
const child =
|
|
809
|
-
const
|
|
810
|
-
const exited =
|
|
1097
|
+
async function runProbation({ profileDir, command, args, graceMs, io = classicIO }) {
|
|
1098
|
+
const child = io.spawn(command, args);
|
|
1099
|
+
const cleanup = io.attach(child);
|
|
1100
|
+
const exited = io.waitFor(child);
|
|
811
1101
|
let timer;
|
|
812
1102
|
const grace = new Promise((resolvePromise) => { timer = setTimeout(() => resolvePromise("grace"), graceMs); });
|
|
813
1103
|
const first = await Promise.race([
|
|
@@ -816,12 +1106,12 @@ async function runProbation({ profileDir, command, args, graceMs }) {
|
|
|
816
1106
|
]);
|
|
817
1107
|
clearTimeout(timer);
|
|
818
1108
|
if (first.phase === "before-grace") {
|
|
819
|
-
|
|
1109
|
+
if (typeof cleanup === "function") await cleanup();
|
|
820
1110
|
return first;
|
|
821
1111
|
}
|
|
822
|
-
commitLaunchSnapshot(profileDir);
|
|
1112
|
+
commitLaunchSnapshot(profileDir, io.say);
|
|
823
1113
|
const result = await exited;
|
|
824
|
-
|
|
1114
|
+
if (typeof cleanup === "function") await cleanup();
|
|
825
1115
|
return { phase: "after-grace", ...result };
|
|
826
1116
|
}
|
|
827
1117
|
|
|
@@ -889,6 +1179,60 @@ function markerLooksValid(marker, profileDir, home) {
|
|
|
889
1179
|
return true;
|
|
890
1180
|
}
|
|
891
1181
|
|
|
1182
|
+
/**
|
|
1183
|
+
* Read and validate a restart plan file, then consume it. The plan is the
|
|
1184
|
+
* visible-restart launch source: `cmd /c start` cannot carry the wrapped
|
|
1185
|
+
* argv, so the Web Host writes it to a nonce-named JSON file and this CLI
|
|
1186
|
+
* reads it back — never through a shell string. A plan that fails validation
|
|
1187
|
+
* (or does not parse) is refused and LEFT ON DISK for diagnosis; only a
|
|
1188
|
+
* successfully loaded plan is deleted. Residue is inert: the file name is
|
|
1189
|
+
* unique per request.
|
|
1190
|
+
*/
|
|
1191
|
+
function loadRestartPlan(planFile) {
|
|
1192
|
+
let raw;
|
|
1193
|
+
try {
|
|
1194
|
+
raw = readFileSync(planFile, "utf8");
|
|
1195
|
+
} catch (error) {
|
|
1196
|
+
throw new Error(`cannot read restart plan ${planFile}: ${error.message}`);
|
|
1197
|
+
}
|
|
1198
|
+
let value;
|
|
1199
|
+
try {
|
|
1200
|
+
value = JSON.parse(raw);
|
|
1201
|
+
} catch (error) {
|
|
1202
|
+
throw new Error(`restart plan ${planFile} is not valid JSON (${error.message}) — file left for inspection`);
|
|
1203
|
+
}
|
|
1204
|
+
const verdict = validateRestartPlanPayload(value);
|
|
1205
|
+
if (!verdict.ok) {
|
|
1206
|
+
throw new Error(`${verdict.error} — in ${planFile} (file left for inspection)`);
|
|
1207
|
+
}
|
|
1208
|
+
try { unlinkSync(planFile); } catch { /* nonce-named residue is inert */ }
|
|
1209
|
+
return verdict.plan;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Resolve what `guard launch` should run from the parsed CLI. Two exclusive
|
|
1214
|
+
* sources: the classic `--profile … -- command argv` form, or a `--plan-file`
|
|
1215
|
+
* that carries all three. Anything overlapping the plan form is a usage error
|
|
1216
|
+
* — two sources for the same fact is how they drift apart.
|
|
1217
|
+
*/
|
|
1218
|
+
function resolveLaunchInvocation(parsed) {
|
|
1219
|
+
if (parsed.planFile !== undefined) {
|
|
1220
|
+
if (parsed.profile !== undefined || parsed.awaitExit !== undefined || parsed.commandArgv.length !== 0) {
|
|
1221
|
+
throw usageError("--plan-file carries --profile, --await-exit and the wrapped command; do not pass them separately (see --help)");
|
|
1222
|
+
}
|
|
1223
|
+
const plan = loadRestartPlan(parsed.planFile);
|
|
1224
|
+
return { profile: plan.profile, commandArgv: [plan.command, ...plan.args], awaitExitPid: plan.awaitExitPid, plan };
|
|
1225
|
+
}
|
|
1226
|
+
if (parsed.profile === undefined) throw usageError("launch needs --profile <name> (see --help)");
|
|
1227
|
+
if (parsed.commandArgv.length === 0) throw usageError("launch needs a command after `--` (see --help)");
|
|
1228
|
+
let awaitExitPid;
|
|
1229
|
+
if (parsed.awaitExit !== undefined) {
|
|
1230
|
+
awaitExitPid = Number(parsed.awaitExit);
|
|
1231
|
+
if (!Number.isInteger(awaitExitPid) || awaitExitPid <= 0) throw usageError(`--await-exit must be a positive process id, got ${JSON.stringify(parsed.awaitExit)}`);
|
|
1232
|
+
}
|
|
1233
|
+
return { profile: parsed.profile, commandArgv: parsed.commandArgv, awaitExitPid };
|
|
1234
|
+
}
|
|
1235
|
+
|
|
892
1236
|
/**
|
|
893
1237
|
* `guard launch`: start dsh (or any command) wrapped in startup probation for
|
|
894
1238
|
* the profile's pending install, if one exists.
|
|
@@ -909,12 +1253,31 @@ async function cmdLaunch({
|
|
|
909
1253
|
graceMs,
|
|
910
1254
|
commandArgv,
|
|
911
1255
|
awaitExitPid,
|
|
1256
|
+
plan,
|
|
1257
|
+
tee,
|
|
1258
|
+
interrupt,
|
|
912
1259
|
_waitForExit = waitForProcessExit,
|
|
913
|
-
_onAwaitExit = announceRestartHelperReady,
|
|
1260
|
+
_onAwaitExit = plan === undefined ? announceRestartHelperReady : (pid) => announceRestartHandoffViaFile(plan, pid),
|
|
914
1261
|
}) {
|
|
915
1262
|
const profileDir = profileDirOf(home, profile);
|
|
916
1263
|
const grace = graceMs ?? DEFAULT_GRACE_MS;
|
|
917
1264
|
const [command, ...args] = commandArgv;
|
|
1265
|
+
// The visible-console launch passes its tee runner: spawns pipe through it,
|
|
1266
|
+
// [guard] lines are mirrored to the window and the log, and probation's
|
|
1267
|
+
// retry-once reuses the same tee. The classic launch keeps classicIO — its
|
|
1268
|
+
// console.log/console.error split (stdout vs stderr) is unchanged behavior.
|
|
1269
|
+
const io = tee ?? classicIO;
|
|
1270
|
+
const say = io.say;
|
|
1271
|
+
// The Ctrl watcher needs to know the live child (second Ctrl+C escalates
|
|
1272
|
+
// to a kill) — wrap attach so every spawn, probation's retry included,
|
|
1273
|
+
// registers it.
|
|
1274
|
+
const watchedIO = interrupt === undefined ? io : {
|
|
1275
|
+
...io,
|
|
1276
|
+
attach(child) {
|
|
1277
|
+
interrupt.noteChild(child);
|
|
1278
|
+
return io.attach(child);
|
|
1279
|
+
},
|
|
1280
|
+
};
|
|
918
1281
|
|
|
919
1282
|
// A restart hands us the pid of the host that is on its way out. Starting
|
|
920
1283
|
// the successor while it still holds the listening port is not a race worth
|
|
@@ -928,12 +1291,30 @@ async function cmdLaunch({
|
|
|
928
1291
|
if (awaitExitPid !== undefined) {
|
|
929
1292
|
// This is the exact handoff boundary: arguments and profile are valid, and
|
|
930
1293
|
// the helper is about to block on the outgoing Host. The Web parent waits
|
|
931
|
-
// for this IPC
|
|
1294
|
+
// for this acknowledgement — over IPC on the background path, or through
|
|
1295
|
+
// the ready file on the visible-console path — before it allows itself to
|
|
1296
|
+
// exit. A failed announcement rejects here and nothing starts.
|
|
1297
|
+
if (tee !== undefined) tee.say(`[guard] waiting for outgoing dsh (pid ${awaitExitPid}) to exit — close this window to cancel…`);
|
|
932
1298
|
await _onAwaitExit(awaitExitPid);
|
|
933
|
-
|
|
1299
|
+
const gone = await _waitForExit(awaitExitPid, AWAIT_EXIT_TIMEOUT_MS, 100, interrupt === undefined ? undefined : interrupt.interrupted);
|
|
1300
|
+
if (!gone) {
|
|
1301
|
+
if (interrupt !== undefined && interrupt.interrupted()) {
|
|
1302
|
+
throw new Error(`interrupted while waiting for the outgoing dsh (pid ${awaitExitPid}) — no successor was started`);
|
|
1303
|
+
}
|
|
934
1304
|
throw new Error(`process ${awaitExitPid} was still running after ${AWAIT_EXIT_TIMEOUT_MS}ms — refusing to start a second host that would collide with it on the listening port (the old one is still up; nothing was changed)`);
|
|
935
1305
|
}
|
|
936
1306
|
await delay(PORT_SETTLE_MS);
|
|
1307
|
+
// Cancel sentinel: the Web parent cannot kill this guard (cmd /c start
|
|
1308
|
+
// hid the pid), so on a handoff it gave up waiting for it drops
|
|
1309
|
+
// <readyFile>.cancel. A slow guard waking up here must not start a second
|
|
1310
|
+
// successor next to the retry's one — that port collision is exactly what
|
|
1311
|
+
// probation would misread as a bad install. The sweep never touches a
|
|
1312
|
+
// fresh sentinel (it may belong to a guard that has not woken yet);
|
|
1313
|
+
// consuming it here deletes it so it does not linger.
|
|
1314
|
+
if (plan !== undefined && existsSync(`${plan.readyFile}.cancel`)) {
|
|
1315
|
+
try { unlinkSync(`${plan.readyFile}.cancel`); } catch { /* best effort */ }
|
|
1316
|
+
throw new Error("this restart was cancelled by the Web parent (the handoff timed out) — not starting a successor");
|
|
1317
|
+
}
|
|
937
1318
|
}
|
|
938
1319
|
// Mirrors guard.js pendingPath(): <home>/guard/pending-<profile>.json.
|
|
939
1320
|
const markerPath = join(home, "guard", `pending-${profile}.json`);
|
|
@@ -961,29 +1342,32 @@ async function cmdLaunch({
|
|
|
961
1342
|
} catch (error) {
|
|
962
1343
|
throw new Error(`profile "${profile}" failed static validation and rollback failed — refusing to launch: ${error.message}`);
|
|
963
1344
|
}
|
|
964
|
-
|
|
965
|
-
for (const entry of [...validation.issues, ...removeValidation.issues])
|
|
1345
|
+
say(`[guard] profile "${profile}" failed static validation — rolled back before launch:`, "error");
|
|
1346
|
+
for (const entry of [...validation.issues, ...removeValidation.issues]) say(renderIssue(entry), "error");
|
|
966
1347
|
} else {
|
|
967
1348
|
pending = true;
|
|
968
1349
|
}
|
|
969
1350
|
}
|
|
970
1351
|
|
|
971
1352
|
if (!pending) {
|
|
972
|
-
const result = await runPlain(command, args);
|
|
973
|
-
if (result.error !== undefined)
|
|
1353
|
+
const result = await runPlain(command, args, watchedIO);
|
|
1354
|
+
if (result.error !== undefined) say(`[guard] failed to start ${command}: ${result.error.message}`, "error");
|
|
1355
|
+
if (isInterruptedOutcome(result, interrupt)) return result.signal === "SIGTERM" ? 143 : 130;
|
|
974
1356
|
return exitCodeOf(result);
|
|
975
1357
|
}
|
|
976
1358
|
|
|
977
|
-
const outcome = await runProbation({ profileDir, command, args, graceMs: grace });
|
|
1359
|
+
const outcome = await runProbation({ profileDir, command, args, graceMs: grace, io: watchedIO });
|
|
978
1360
|
if (outcome.phase === "after-grace") return exitCodeOf(outcome);
|
|
979
|
-
if (outcome
|
|
980
|
-
// Interrupted from outside (Ctrl+C / service stop): not an install
|
|
981
|
-
// leave the marker pending for the next launch
|
|
982
|
-
|
|
1361
|
+
if (isInterruptedOutcome(outcome, interrupt)) {
|
|
1362
|
+
// Interrupted from outside (Ctrl+C / service stop): not an install
|
|
1363
|
+
// failure — leave the marker pending for the next launch and keep the
|
|
1364
|
+
// 130/143 convention. This check precedes the exit-0 commit on purpose:
|
|
1365
|
+
// a dsh that exits 0 after a Ctrl+C has proven nothing about loading.
|
|
1366
|
+
return outcome.signal === "SIGTERM" ? 143 : 130;
|
|
983
1367
|
}
|
|
984
1368
|
if (outcome.error === undefined && outcome.code === 0) {
|
|
985
1369
|
// One-shot command that finished successfully inside the grace window.
|
|
986
|
-
commitLaunchSnapshot(profileDir);
|
|
1370
|
+
commitLaunchSnapshot(profileDir, say);
|
|
987
1371
|
return 0;
|
|
988
1372
|
}
|
|
989
1373
|
|
|
@@ -998,9 +1382,10 @@ async function cmdLaunch({
|
|
|
998
1382
|
} catch (error) {
|
|
999
1383
|
throw new Error(`the command ${why} within the grace period, but rollback failed — refusing to restart: ${error.message}`);
|
|
1000
1384
|
}
|
|
1001
|
-
|
|
1002
|
-
const retry = await runPlain(command, args);
|
|
1003
|
-
if (retry.error !== undefined)
|
|
1385
|
+
say(`[guard] the command ${why} within the ${grace}ms grace period — profile "${profile}" rolled back, restarting once with the restored state`, "error");
|
|
1386
|
+
const retry = await runPlain(command, args, watchedIO);
|
|
1387
|
+
if (retry.error !== undefined) say(`[guard] failed to restart ${command}: ${retry.error.message}`, "error");
|
|
1388
|
+
if (isInterruptedOutcome(retry, interrupt)) return retry.signal === "SIGTERM" ? 143 : 130;
|
|
1004
1389
|
return exitCodeOf(retry);
|
|
1005
1390
|
}
|
|
1006
1391
|
|
|
@@ -1016,6 +1401,7 @@ Usage:
|
|
|
1016
1401
|
node src/cli.js guard add <spec> --profile <name> [--home <dir>] [--accept-warnings]
|
|
1017
1402
|
node src/cli.js guard remove <package> --profile <name> [--home <dir>]
|
|
1018
1403
|
node src/cli.js guard launch --profile <name> [--home <dir>] [--grace-ms <ms>] [--await-exit <pid>] -- <command> [args...]
|
|
1404
|
+
node src/cli.js guard launch --plan-file <path> [--home <dir>] [--grace-ms <ms>]
|
|
1019
1405
|
node src/cli.js guard self-test
|
|
1020
1406
|
|
|
1021
1407
|
Commands:
|
|
@@ -1043,7 +1429,11 @@ Commands:
|
|
|
1043
1429
|
period rolls the profile back and restarts the exact command once
|
|
1044
1430
|
with the restored state (never loops). A corrupt or legacy (pre-v2)
|
|
1045
1431
|
marker fails closed: the command is not launched and nothing is
|
|
1046
|
-
deleted.
|
|
1432
|
+
deleted. --plan-file loads the wrapped command from a restart
|
|
1433
|
+
plan JSON instead of \`--\` (it carries --profile, --await-exit
|
|
1434
|
+
and the command itself; passing those alongside is a usage
|
|
1435
|
+
error) and is consumed on load — an invalid plan is refused and
|
|
1436
|
+
left on disk for inspection.
|
|
1047
1437
|
self-test run offline fixtures (no network, no pnpm/dsh).
|
|
1048
1438
|
|
|
1049
1439
|
Exit codes: 0 ok, 1 blocked/rolled back/failed, 2 usage error; launch preserves
|
|
@@ -1056,7 +1446,7 @@ function parseArgs(argv) {
|
|
|
1056
1446
|
if (args[0] === "guard") args.shift(); // `node cli.js guard recover` / `node cli.js recover`
|
|
1057
1447
|
let command = args.shift() ?? "help";
|
|
1058
1448
|
if (command === "--help" || command === "-h") command = "help"; // `cli.js --help`
|
|
1059
|
-
const opts = { home: undefined, profile: undefined, graceMs: undefined, awaitExit: undefined, acceptWarnings: false, positionals: [], commandArgv: [] };
|
|
1449
|
+
const opts = { home: undefined, profile: undefined, graceMs: undefined, awaitExit: undefined, planFile: undefined, acceptWarnings: false, positionals: [], commandArgv: [] };
|
|
1060
1450
|
for (let index = 0; index < args.length; index++) {
|
|
1061
1451
|
const arg = args[index];
|
|
1062
1452
|
if (arg === "--") { opts.commandArgv = args.slice(index + 1); break; } // launch: the wrapped command, verbatim
|
|
@@ -1070,6 +1460,8 @@ function parseArgs(argv) {
|
|
|
1070
1460
|
if (arg.startsWith("--grace-ms=")) { opts.graceMs = arg.slice("--grace-ms=".length); continue; }
|
|
1071
1461
|
if (arg === "--await-exit") { opts.awaitExit = args[++index]; continue; }
|
|
1072
1462
|
if (arg.startsWith("--await-exit=")) { opts.awaitExit = arg.slice("--await-exit=".length); continue; }
|
|
1463
|
+
if (arg === "--plan-file") { opts.planFile = args[++index]; continue; }
|
|
1464
|
+
if (arg.startsWith("--plan-file=")) { opts.planFile = arg.slice("--plan-file=".length); continue; }
|
|
1073
1465
|
if (arg === "--all") { opts.all = true; continue; }
|
|
1074
1466
|
if (arg.startsWith("-")) throw new Error(`unknown option ${JSON.stringify(arg)}`);
|
|
1075
1467
|
opts.positionals.push(arg);
|
|
@@ -1094,6 +1486,13 @@ async function main(argv) {
|
|
|
1094
1486
|
return;
|
|
1095
1487
|
}
|
|
1096
1488
|
const home = parsed.home !== undefined ? resolve(parsed.home) : resolveDshHome();
|
|
1489
|
+
// The visible-restart tee outlives cmdLaunch: main's catch can still write
|
|
1490
|
+
// the failure into the restart log (the window would otherwise flash away)
|
|
1491
|
+
// before the log is closed and flushed. The Ctrl watcher belongs to the
|
|
1492
|
+
// same lifetime — Windows only, so every other platform's signal behavior
|
|
1493
|
+
// stays exactly as it was.
|
|
1494
|
+
let launchTee;
|
|
1495
|
+
let launchInterrupt;
|
|
1097
1496
|
try {
|
|
1098
1497
|
switch (parsed.command) {
|
|
1099
1498
|
case "validate": {
|
|
@@ -1133,19 +1532,17 @@ async function main(argv) {
|
|
|
1133
1532
|
return;
|
|
1134
1533
|
}
|
|
1135
1534
|
case "launch": {
|
|
1136
|
-
if (parsed.profile === undefined) throw usageError("launch needs --profile <name> (see --help)");
|
|
1137
|
-
if (parsed.commandArgv.length === 0) throw usageError("launch needs a command after `--` (see --help)");
|
|
1138
1535
|
let graceMs;
|
|
1139
1536
|
if (parsed.graceMs !== undefined) {
|
|
1140
1537
|
graceMs = Number(parsed.graceMs);
|
|
1141
1538
|
if (!Number.isFinite(graceMs) || graceMs < 0) throw usageError(`--grace-ms must be a non-negative number, got ${JSON.stringify(parsed.graceMs)}`);
|
|
1142
1539
|
}
|
|
1143
|
-
|
|
1144
|
-
if (
|
|
1145
|
-
|
|
1146
|
-
if (
|
|
1540
|
+
const invocation = resolveLaunchInvocation(parsed);
|
|
1541
|
+
if (invocation.plan !== undefined) {
|
|
1542
|
+
launchTee = createTeeRunner({ logPath: invocation.plan.logPath, cwd: invocation.plan.cwd });
|
|
1543
|
+
if (process.platform === "win32") launchInterrupt = createInterruptWatcher();
|
|
1147
1544
|
}
|
|
1148
|
-
process.exitCode = await cmdLaunch({
|
|
1545
|
+
process.exitCode = await cmdLaunch({ ...invocation, home, graceMs, tee: launchTee, interrupt: launchInterrupt });
|
|
1149
1546
|
return;
|
|
1150
1547
|
}
|
|
1151
1548
|
case "self-test": {
|
|
@@ -1156,8 +1553,19 @@ async function main(argv) {
|
|
|
1156
1553
|
throw usageError(`unknown command ${JSON.stringify(parsed.command)} (see --help)`);
|
|
1157
1554
|
}
|
|
1158
1555
|
} catch (error) {
|
|
1556
|
+
if (launchTee !== undefined) launchTee.say(`error: ${error.message}`, "error");
|
|
1159
1557
|
console.error(`error: ${error.message}`);
|
|
1160
1558
|
process.exitCode = error instanceof UsageError ? 2 : 1;
|
|
1559
|
+
// A visible window that vanishes takes its diagnosis with it: hold it
|
|
1560
|
+
// briefly so the error can be read (a working console signal cuts the
|
|
1561
|
+
// wait short).
|
|
1562
|
+
if (launchTee !== undefined) {
|
|
1563
|
+
launchTee.say("[guard] this window closes in 10s so the error above can be read…");
|
|
1564
|
+
await waitForSignalOrTimeout(10000, launchInterrupt);
|
|
1565
|
+
}
|
|
1566
|
+
} finally {
|
|
1567
|
+
if (launchInterrupt !== undefined) launchInterrupt.unlisten();
|
|
1568
|
+
if (launchTee !== undefined) await launchTee.close();
|
|
1161
1569
|
}
|
|
1162
1570
|
}
|
|
1163
1571
|
|
|
@@ -1229,6 +1637,88 @@ async function selfTest() {
|
|
|
1229
1637
|
}
|
|
1230
1638
|
}
|
|
1231
1639
|
|
|
1640
|
+
// --plan-file: the visible-restart launch source. Parsing accepts both
|
|
1641
|
+
// forms; resolution treats the plan as the single source of truth, drinks
|
|
1642
|
+
// it on load, and refuses to mix it with --profile/--await-exit/`--`.
|
|
1643
|
+
{
|
|
1644
|
+
const p7 = parseArgs(["launch", "--plan-file", "C:/r/plan.json"]);
|
|
1645
|
+
if (p7.planFile !== "C:/r/plan.json") throw new Error("parseArgs --plan-file fixture failed");
|
|
1646
|
+
const p8 = parseArgs(["launch", "--plan-file=C:/r/plan.json"]);
|
|
1647
|
+
if (p8.planFile !== "C:/r/plan.json") throw new Error("parseArgs --plan-file= fixture failed");
|
|
1648
|
+
|
|
1649
|
+
const planDir = join(root, "restart-plans");
|
|
1650
|
+
mkdirSync(planDir, { recursive: true });
|
|
1651
|
+
const writePlan = (name, overrides = {}) => {
|
|
1652
|
+
const path = join(planDir, name);
|
|
1653
|
+
writeFileSync(path, JSON.stringify({
|
|
1654
|
+
version: 1,
|
|
1655
|
+
type: RESTART_PLAN_TYPE,
|
|
1656
|
+
profile: "web",
|
|
1657
|
+
awaitExitPid: 4321,
|
|
1658
|
+
logPath: join(planDir, "restart-web.log"),
|
|
1659
|
+
readyFile: join(planDir, "ready.json"),
|
|
1660
|
+
cwd: root,
|
|
1661
|
+
command: process.execPath,
|
|
1662
|
+
args: ["-e", "process.exit(0)"],
|
|
1663
|
+
...overrides,
|
|
1664
|
+
}));
|
|
1665
|
+
return path;
|
|
1666
|
+
};
|
|
1667
|
+
|
|
1668
|
+
const goodPath = writePlan("good.json");
|
|
1669
|
+
const invocation = resolveLaunchInvocation({ planFile: goodPath, commandArgv: [] });
|
|
1670
|
+
if (invocation.profile !== "web" || invocation.awaitExitPid !== 4321) throw new Error("plan invocation must carry the plan's profile and pid");
|
|
1671
|
+
if (invocation.commandArgv[0] !== process.execPath || invocation.commandArgv[2] !== "process.exit(0)") throw new Error("plan invocation must derive the wrapped command from the plan");
|
|
1672
|
+
if (existsSync(goodPath)) throw new Error("a successfully loaded plan must be consumed (deleted)");
|
|
1673
|
+
|
|
1674
|
+
for (const [name, overrides] of [["bad-args.json", { args: [] }], ["bad-version.json", { version: 2 }]]) {
|
|
1675
|
+
const badPath = writePlan(name, overrides);
|
|
1676
|
+
let refused = false;
|
|
1677
|
+
try {
|
|
1678
|
+
resolveLaunchInvocation({ planFile: badPath, commandArgv: [] });
|
|
1679
|
+
} catch (error) {
|
|
1680
|
+
refused = !(error instanceof UsageError) && /restart plan/.test(error.message);
|
|
1681
|
+
}
|
|
1682
|
+
if (!refused) throw new Error(`an invalid plan (${name}) must fail closed`);
|
|
1683
|
+
if (!existsSync(badPath)) throw new Error(`an invalid plan (${name}) must be left on disk for inspection`);
|
|
1684
|
+
}
|
|
1685
|
+
const notJsonPath = join(planDir, "not-json.json");
|
|
1686
|
+
writeFileSync(notJsonPath, "{ half-written");
|
|
1687
|
+
let notJsonRefused = false;
|
|
1688
|
+
try {
|
|
1689
|
+
resolveLaunchInvocation({ planFile: notJsonPath, commandArgv: [] });
|
|
1690
|
+
} catch (error) {
|
|
1691
|
+
notJsonRefused = /not valid JSON/.test(error.message);
|
|
1692
|
+
}
|
|
1693
|
+
if (!notJsonRefused || !existsSync(notJsonPath)) throw new Error("unparseable plan JSON must be refused and preserved");
|
|
1694
|
+
let missingRefused = false;
|
|
1695
|
+
try {
|
|
1696
|
+
resolveLaunchInvocation({ planFile: join(planDir, "nope.json"), commandArgv: [] });
|
|
1697
|
+
} catch (error) {
|
|
1698
|
+
missingRefused = /cannot read restart plan/.test(error.message);
|
|
1699
|
+
}
|
|
1700
|
+
if (!missingRefused) throw new Error("a missing plan file must fail with a clear error");
|
|
1701
|
+
|
|
1702
|
+
for (const extra of [{ profile: "web" }, { awaitExit: "4321" }, { commandArgv: ["dsh"] }]) {
|
|
1703
|
+
let usageErrorSeen = false;
|
|
1704
|
+
try {
|
|
1705
|
+
resolveLaunchInvocation({ planFile: writePlan("exclusive.json"), commandArgv: [], ...extra });
|
|
1706
|
+
} catch (error) {
|
|
1707
|
+
usageErrorSeen = error instanceof UsageError;
|
|
1708
|
+
}
|
|
1709
|
+
if (!usageErrorSeen) throw new Error(`--plan-file with ${JSON.stringify(Object.keys(extra))} must be a usage error`);
|
|
1710
|
+
}
|
|
1711
|
+
const classic = resolveLaunchInvocation({ profile: "web", commandArgv: ["dsh"], awaitExit: undefined, planFile: undefined });
|
|
1712
|
+
if (classic.profile !== "web" || classic.commandArgv.join(" ") !== "dsh" || classic.plan !== undefined) throw new Error("the classic launch form must resolve unchanged");
|
|
1713
|
+
let classicUsage = false;
|
|
1714
|
+
try {
|
|
1715
|
+
resolveLaunchInvocation({ profile: undefined, commandArgv: [], awaitExit: undefined, planFile: undefined });
|
|
1716
|
+
} catch (error) {
|
|
1717
|
+
classicUsage = error instanceof UsageError;
|
|
1718
|
+
}
|
|
1719
|
+
if (!classicUsage) throw new Error("classic launch without --profile must stay a usage error");
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1232
1722
|
// `--await-exit`: the successor must not start while the outgoing host is
|
|
1233
1723
|
// still holding the port. A pid that never goes away is refused outright —
|
|
1234
1724
|
// starting anyway is what costs the user their install, because the failed
|
|
@@ -1278,6 +1768,85 @@ async function selfTest() {
|
|
|
1278
1768
|
if (!await waitForProcessExit(undefined, 500, 20)) throw new Error("waitForProcessExit must not block on a missing pid");
|
|
1279
1769
|
}
|
|
1280
1770
|
|
|
1771
|
+
// --plan-file launch mode: the handoff announcement moves to the ready
|
|
1772
|
+
// file (there is no IPC channel under `cmd /c start`) while everything
|
|
1773
|
+
// else — waiting for the outgoing pid, running the wrapped command —
|
|
1774
|
+
// behaves exactly like the classic form. An announcement that cannot be
|
|
1775
|
+
// written must reject the launch: the Web parent would otherwise time out
|
|
1776
|
+
// blind while no successor ever started.
|
|
1777
|
+
{
|
|
1778
|
+
const p = join(root, "profiles", "plan-launch");
|
|
1779
|
+
mkdirSync(p, { recursive: true });
|
|
1780
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: {} }));
|
|
1781
|
+
writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
|
|
1782
|
+
const planDir = join(root, "restart-plans-live");
|
|
1783
|
+
mkdirSync(planDir, { recursive: true });
|
|
1784
|
+
const readyOut = join(planDir, "ready-out.json");
|
|
1785
|
+
const plan = {
|
|
1786
|
+
version: 1,
|
|
1787
|
+
type: RESTART_PLAN_TYPE,
|
|
1788
|
+
profile: "plan-launch",
|
|
1789
|
+
awaitExitPid: 8642,
|
|
1790
|
+
logPath: join(planDir, "restart-plan-launch.log"),
|
|
1791
|
+
readyFile: readyOut,
|
|
1792
|
+
cwd: root,
|
|
1793
|
+
command: process.execPath,
|
|
1794
|
+
args: ["-e", "process.exit(0)"],
|
|
1795
|
+
};
|
|
1796
|
+
const code = await cmdLaunch({
|
|
1797
|
+
profile: plan.profile,
|
|
1798
|
+
home: root,
|
|
1799
|
+
commandArgv: [plan.command, ...plan.args],
|
|
1800
|
+
awaitExitPid: plan.awaitExitPid,
|
|
1801
|
+
plan,
|
|
1802
|
+
_waitForExit: async () => true,
|
|
1803
|
+
});
|
|
1804
|
+
if (code !== 0) throw new Error("a plan-file launch must run the wrapped command");
|
|
1805
|
+
if (!existsSync(readyOut)) throw new Error("plan-file launch must publish the ready file before waiting");
|
|
1806
|
+
const readyPayload = JSON.parse(readFileSync(readyOut, "utf8"));
|
|
1807
|
+
if (readyPayload.type !== "@1e0zj/dsh-plugin-mall:restart-helper-ready"
|
|
1808
|
+
|| readyPayload.protocol !== 1 || readyPayload.awaitExitPid !== plan.awaitExitPid
|
|
1809
|
+
|| readyPayload.guardPid !== process.pid) {
|
|
1810
|
+
throw new Error("the ready file must carry the helper-ready message with this guard's pid");
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
const badPlan = { ...plan, readyFile: join(planDir, "no-such-dir", "ready.json") };
|
|
1814
|
+
let announceFailed = false;
|
|
1815
|
+
try {
|
|
1816
|
+
await cmdLaunch({
|
|
1817
|
+
profile: badPlan.profile,
|
|
1818
|
+
home: root,
|
|
1819
|
+
commandArgv: [badPlan.command, ...badPlan.args],
|
|
1820
|
+
awaitExitPid: badPlan.awaitExitPid,
|
|
1821
|
+
plan: badPlan,
|
|
1822
|
+
_waitForExit: async () => true,
|
|
1823
|
+
});
|
|
1824
|
+
} catch {
|
|
1825
|
+
announceFailed = true;
|
|
1826
|
+
}
|
|
1827
|
+
if (!announceFailed) throw new Error("a ready-file write failure must reject the launch (fail closed)");
|
|
1828
|
+
|
|
1829
|
+
// Cancel sentinel: a guard the Web parent gave up on must not start a
|
|
1830
|
+
// successor after its await-exit wait ends — the retry's guard will.
|
|
1831
|
+
// Consuming the sentinel deletes it, so it does not linger.
|
|
1832
|
+
writeFileSync(`${readyOut}.cancel`, "cancelled (fixture)\n");
|
|
1833
|
+
let cancelled = false;
|
|
1834
|
+
try {
|
|
1835
|
+
await cmdLaunch({
|
|
1836
|
+
profile: plan.profile,
|
|
1837
|
+
home: root,
|
|
1838
|
+
commandArgv: [plan.command, ...plan.args],
|
|
1839
|
+
awaitExitPid: plan.awaitExitPid,
|
|
1840
|
+
plan,
|
|
1841
|
+
_waitForExit: async () => true,
|
|
1842
|
+
});
|
|
1843
|
+
} catch (error) {
|
|
1844
|
+
cancelled = /cancelled by the Web parent/.test(error.message);
|
|
1845
|
+
}
|
|
1846
|
+
if (!cancelled) throw new Error("a cancelled handoff must refuse to start a successor after the wait");
|
|
1847
|
+
if (existsSync(`${readyOut}.cancel`)) throw new Error("consuming the cancel sentinel must delete it");
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1281
1850
|
// hideChildConsole: inherit the console we have, never create one we do
|
|
1282
1851
|
// not. Getting this backwards is visible either way — a stray blank window
|
|
1283
1852
|
// after every restart, or an interactive `guard launch` that has lost its
|
|
@@ -1297,6 +1866,296 @@ async function selfTest() {
|
|
|
1297
1866
|
}
|
|
1298
1867
|
}
|
|
1299
1868
|
|
|
1869
|
+
// Tee runner (visible restart): the wrapped command's output must reach
|
|
1870
|
+
// BOTH the console target and the log byte-for-byte, the exit code must
|
|
1871
|
+
// pass through, everything must be flushed before close() settles, a
|
|
1872
|
+
// broken log must not change the outcome, and backpressure must not lose
|
|
1873
|
+
// data. Classic IO keeps its exact behavior (existing fixtures above).
|
|
1874
|
+
{
|
|
1875
|
+
const teeDir = join(root, "tee");
|
|
1876
|
+
mkdirSync(teeDir, { recursive: true });
|
|
1877
|
+
|
|
1878
|
+
// Do not call process.exit() after the large write: on Linux that
|
|
1879
|
+
// truncates the CHILD's pipe before the tee can ever read it (the exact
|
|
1880
|
+
// false failure was 146176/628890 bytes in both destinations). Setting
|
|
1881
|
+
// exitCode preserves the nonzero result while Node drains stdout first.
|
|
1882
|
+
const generate = "const lines = []; for (let i = 0; i < 20000; i++) lines.push('line-' + i + '-' + 'x'.repeat(20)); process.stdout.write(lines.join('\\n') + '\\n'); process.exitCode = 7;";
|
|
1883
|
+
const expected = `${Array.from({ length: 20000 }, (_, index) => `line-${index}-${"x".repeat(20)}`).join("\n")}\n`;
|
|
1884
|
+
const fastChunks = [];
|
|
1885
|
+
const fastOut = new Writable({
|
|
1886
|
+
write(chunk, encoding, callback) { fastChunks.push(Buffer.from(chunk)); callback(); },
|
|
1887
|
+
});
|
|
1888
|
+
const runner = createTeeRunner({ logPath: join(teeDir, "restart-tee.log"), cwd: root, _stdout: fastOut });
|
|
1889
|
+
const child = runner.spawn(process.execPath, ["-e", generate]);
|
|
1890
|
+
// stdin must NOT be the console: an inherited TTY stdin lets the wrapped
|
|
1891
|
+
// dsh flip the console into raw mode, which kills Ctrl+C for EVERYONE
|
|
1892
|
+
// attached (live-verified: consoleMode was 0x0000 in the first test).
|
|
1893
|
+
if (child.stdin !== null) throw new Error("the tee'd child must not own the console stdin");
|
|
1894
|
+
const cleanup = runner.attach(child);
|
|
1895
|
+
const result = await runner.waitFor(child);
|
|
1896
|
+
if (typeof cleanup === "function") await cleanup();
|
|
1897
|
+
await runner.close();
|
|
1898
|
+
if (result.code !== 7) throw new Error("tee must preserve the child's exit code");
|
|
1899
|
+
const logged = readFileSync(join(teeDir, "restart-tee.log"), "utf8");
|
|
1900
|
+
const seen = Buffer.concat(fastChunks).toString("utf8");
|
|
1901
|
+
if (logged !== expected || seen !== expected) throw new Error(`tee must be byte-identical on both targets (log ${logged.length}, console ${seen.length}, expected ${expected.length})`);
|
|
1902
|
+
if (runner.broken) throw new Error("a healthy tee log must not be marked broken");
|
|
1903
|
+
|
|
1904
|
+
const errRunner = createTeeRunner({
|
|
1905
|
+
logPath: join(teeDir, "restart-err.log"),
|
|
1906
|
+
cwd: root,
|
|
1907
|
+
_stdout: new Writable({ write(chunk, encoding, callback) { callback(); } }),
|
|
1908
|
+
});
|
|
1909
|
+
const errChild = errRunner.spawn(process.execPath, ["-e", "process.stderr.write('ERR-OUT\\n'); process.stdout.write('OUT\\n'); process.exit(0)"]);
|
|
1910
|
+
const errCleanup = errRunner.attach(errChild);
|
|
1911
|
+
await errRunner.waitFor(errChild);
|
|
1912
|
+
if (typeof errCleanup === "function") await errCleanup();
|
|
1913
|
+
await errRunner.close();
|
|
1914
|
+
const errLogged = readFileSync(join(teeDir, "restart-err.log"), "utf8");
|
|
1915
|
+
if (!errLogged.includes("ERR-OUT") || !errLogged.includes("OUT")) throw new Error("tee must mirror stderr as well as stdout");
|
|
1916
|
+
|
|
1917
|
+
// The tee'd child must carry the visible-chain flag: its own restart
|
|
1918
|
+
// decision depends on it (stdout is a pipe here, the TTY is gone).
|
|
1919
|
+
const chainRunner = createTeeRunner({
|
|
1920
|
+
logPath: join(teeDir, "chain.log"),
|
|
1921
|
+
cwd: root,
|
|
1922
|
+
_stdout: new Writable({ write(chunk, encoding, callback) { callback(); } }),
|
|
1923
|
+
});
|
|
1924
|
+
const chainChild = chainRunner.spawn(process.execPath, ["-e", "console.log(process.env.DSH_PLUGIN_MALL_VISIBLE_CONSOLE ?? 'unset')"]);
|
|
1925
|
+
const chainCleanup = chainRunner.attach(chainChild);
|
|
1926
|
+
await chainRunner.waitFor(chainChild);
|
|
1927
|
+
if (typeof chainCleanup === "function") await chainCleanup();
|
|
1928
|
+
await chainRunner.close();
|
|
1929
|
+
if (!readFileSync(join(teeDir, "chain.log"), "utf8").includes("1")) throw new Error("the tee'd child must inherit DSH_PLUGIN_MALL_VISIBLE_CONSOLE=1");
|
|
1930
|
+
|
|
1931
|
+
const consoleChunks = [];
|
|
1932
|
+
const captureOut = new Writable({
|
|
1933
|
+
write(chunk, encoding, callback) { consoleChunks.push(Buffer.from(chunk)); callback(); },
|
|
1934
|
+
});
|
|
1935
|
+
const brokenRunner = createTeeRunner({
|
|
1936
|
+
logPath: join(teeDir, "never.log"),
|
|
1937
|
+
cwd: root,
|
|
1938
|
+
_stdout: captureOut,
|
|
1939
|
+
_openLog: () => { throw new Error("disk on fire"); },
|
|
1940
|
+
});
|
|
1941
|
+
const brokenChild = brokenRunner.spawn(process.execPath, ["-e", "process.stdout.write('STILL-HERE\\n'); process.exit(5)"]);
|
|
1942
|
+
const brokenCleanup = brokenRunner.attach(brokenChild);
|
|
1943
|
+
const brokenResult = await brokenRunner.waitFor(brokenChild);
|
|
1944
|
+
if (typeof brokenCleanup === "function") await brokenCleanup();
|
|
1945
|
+
await brokenRunner.close();
|
|
1946
|
+
const consoleText = Buffer.concat(consoleChunks).toString("utf8");
|
|
1947
|
+
if (brokenResult.code !== 5 || !brokenRunner.broken) throw new Error("a failed log must be console-only and must not affect the child");
|
|
1948
|
+
if (!consoleText.includes("STILL-HERE") || !consoleText.includes("disk on fire")) throw new Error("a failed log must warn on the console and keep showing output");
|
|
1949
|
+
|
|
1950
|
+
// A log that fails WHILE SATURATED must release the backpressure: a
|
|
1951
|
+
// dead stream never drains, and the paused source pipes would otherwise
|
|
1952
|
+
// stay paused forever — the wrapped dsh would hang mid-output.
|
|
1953
|
+
{
|
|
1954
|
+
let errorSink = null;
|
|
1955
|
+
const evilLog = new EventEmitter();
|
|
1956
|
+
evilLog.write = () => false; // always saturated, never drains
|
|
1957
|
+
const recoverChunks = [];
|
|
1958
|
+
const recoverOut = new Writable({
|
|
1959
|
+
write(chunk, encoding, callback) { recoverChunks.push(Buffer.from(chunk)); callback(); },
|
|
1960
|
+
});
|
|
1961
|
+
const recoverRunner = createTeeRunner({
|
|
1962
|
+
logPath: join(teeDir, "evil.log"),
|
|
1963
|
+
cwd: root,
|
|
1964
|
+
_stdout: recoverOut,
|
|
1965
|
+
_openLog: () => {
|
|
1966
|
+
errorSink = evilLog;
|
|
1967
|
+
return evilLog;
|
|
1968
|
+
},
|
|
1969
|
+
});
|
|
1970
|
+
const recoverChild = recoverRunner.spawn(process.execPath, ["-e", `
|
|
1971
|
+
process.stdout.write('BEFORE-ERROR\\n');
|
|
1972
|
+
setTimeout(() => { process.stdout.write('AFTER-ERROR\\n'); process.exit(3); }, 700);
|
|
1973
|
+
`]);
|
|
1974
|
+
const recoverCleanup = recoverRunner.attach(recoverChild);
|
|
1975
|
+
// The first data block saturates the log and pauses the pipes; only
|
|
1976
|
+
// THEN does the log fail — the second block can only arrive if the
|
|
1977
|
+
// error path released the backpressure and resumed them.
|
|
1978
|
+
recoverChild.stdout.once("data", () => {
|
|
1979
|
+
setImmediate(() => { evilLog.emit("error", new Error("ENOSPC: log disk full")); });
|
|
1980
|
+
});
|
|
1981
|
+
const recoverResult = await recoverRunner.waitFor(recoverChild);
|
|
1982
|
+
if (typeof recoverCleanup === "function") await recoverCleanup();
|
|
1983
|
+
await recoverRunner.close();
|
|
1984
|
+
const recoverText = Buffer.concat(recoverChunks).toString("utf8");
|
|
1985
|
+
if (recoverResult.code !== 3 || !recoverRunner.broken) throw new Error("a mid-run log failure must not affect the child");
|
|
1986
|
+
if (!recoverText.includes("BEFORE-ERROR") || !recoverText.includes("AFTER-ERROR")) throw new Error("a mid-run log failure must resume the paused pipes — output after the error must still arrive");
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
// The slow target is a plain EventEmitter, not a Writable: _write is a
|
|
1990
|
+
// serialization point (queued writes never run once the releaser stops),
|
|
1991
|
+
// which would lose TAIL bytes here and misblame the tee. write() always
|
|
1992
|
+
// reports saturated, every drain is hand-released — the strictest
|
|
1993
|
+
// pause/resume cycle the runner can be put through.
|
|
1994
|
+
const slowChunks = [];
|
|
1995
|
+
const pendingDrains = [];
|
|
1996
|
+
const slowOut = new EventEmitter();
|
|
1997
|
+
slowOut.write = (chunk) => {
|
|
1998
|
+
slowChunks.push(Buffer.from(chunk));
|
|
1999
|
+
pendingDrains.push(() => slowOut.emit("drain"));
|
|
2000
|
+
return false;
|
|
2001
|
+
};
|
|
2002
|
+
const slowRunner = createTeeRunner({ logPath: join(teeDir, "slow.log"), cwd: root, _stdout: slowOut });
|
|
2003
|
+
// The child paces itself on its own backpressure and exits naturally —
|
|
2004
|
+
// process.exit() would truncate its in-flight stdout, which is a child
|
|
2005
|
+
// bug the tee must not be blamed for.
|
|
2006
|
+
const slowChild = slowRunner.spawn(process.execPath, ["-e", `
|
|
2007
|
+
const chunk = 'y'.repeat(4096) + '\\n';
|
|
2008
|
+
let index = 0;
|
|
2009
|
+
function pump() {
|
|
2010
|
+
while (index < 100) {
|
|
2011
|
+
index += 1;
|
|
2012
|
+
if (!process.stdout.write(chunk)) { process.stdout.once('drain', pump); return; }
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
pump();
|
|
2016
|
+
`]);
|
|
2017
|
+
const slowCleanup = slowRunner.attach(slowChild);
|
|
2018
|
+
const slowDone = slowRunner.waitFor(slowChild);
|
|
2019
|
+
const releaser = setInterval(() => { const release = pendingDrains.shift(); if (release !== undefined) release(); }, 1);
|
|
2020
|
+
const slowResult = await slowDone;
|
|
2021
|
+
clearInterval(releaser);
|
|
2022
|
+
if (typeof slowCleanup === "function") await slowCleanup();
|
|
2023
|
+
await slowRunner.close();
|
|
2024
|
+
const total = 100 * (4096 + 1);
|
|
2025
|
+
const slowSeen = slowChunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
2026
|
+
const slowLogged = readFileSync(join(teeDir, "slow.log"), "utf8");
|
|
2027
|
+
if (slowResult.code !== 0 || slowSeen !== total || slowLogged.length !== total) {
|
|
2028
|
+
throw new Error(`backpressure must not lose data (console ${slowSeen}, log ${slowLogged.length}, expected ${total})`);
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// Console Ctrl watcher: the first Ctrl+C only marks the run interrupted;
|
|
2033
|
+
// after forceGraceMs (or a second Ctrl+C) the child's whole process TREE
|
|
2034
|
+
// is torn down (a bare kill leaves dsh's grandchildren attached to the
|
|
2035
|
+
// console — the "window will not close" case). waitForProcessExit honors
|
|
2036
|
+
// the flag.
|
|
2037
|
+
{
|
|
2038
|
+
const fakeChild = { pid: 31337, killCalls: 0, kill() { this.killCalls += 1; } };
|
|
2039
|
+
const treeKills = [];
|
|
2040
|
+
const watcher = createInterruptWatcher({ forceGraceMs: 25, _killTree: (pid) => treeKills.push(pid) });
|
|
2041
|
+
watcher.noteChild(fakeChild);
|
|
2042
|
+
process.emit("SIGINT");
|
|
2043
|
+
if (!watcher.interrupted()) throw new Error("the first Ctrl+C must mark the watcher interrupted (and not kill yet)");
|
|
2044
|
+
if (fakeChild.killCalls !== 0 || treeKills.length !== 0) throw new Error("the first Ctrl+C must give the child its own chance to exit");
|
|
2045
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 60));
|
|
2046
|
+
if (treeKills.length !== 1 || treeKills[0] !== 31337) throw new Error("the force-deadline must tear down the tree of a child that ignored Ctrl+C");
|
|
2047
|
+
process.emit("SIGINT");
|
|
2048
|
+
if (treeKills.length !== 2) throw new Error("a second Ctrl+C must escalate at once");
|
|
2049
|
+
let aborted = false;
|
|
2050
|
+
watcher.onAbort(() => { aborted = true; });
|
|
2051
|
+
process.emit("SIGBREAK");
|
|
2052
|
+
if (!aborted) throw new Error("inspection pauses must be cut short by any further console signal");
|
|
2053
|
+
watcher.unlisten();
|
|
2054
|
+
if (await waitForProcessExit(process.pid, 5000, 10, () => true) !== false) throw new Error("waitForProcessExit must abort on shouldAbort instead of waiting out the timeout");
|
|
2055
|
+
if (!await waitForProcessExit(0x7ffffff1, 500, 20, () => false)) throw new Error("waitForProcessExit without an abort keeps its liveness semantics");
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
// The anti-misrollback pin: a Ctrl+C during the grace window is NOT a
|
|
2059
|
+
// crash. On Windows the killed child reports code 1 / no signal — without
|
|
2060
|
+
// the watcher's flag probation would roll back a healthy install and
|
|
2061
|
+
// restart dsh in a window the user just tried to close.
|
|
2062
|
+
{
|
|
2063
|
+
const mkInterruptProfile = (homeName) => {
|
|
2064
|
+
const ihome = join(root, homeName);
|
|
2065
|
+
const iprofile = join(ihome, "profiles", "web");
|
|
2066
|
+
const goodDir = join(iprofile, "node_modules", "good");
|
|
2067
|
+
mkdirSync(goodDir, { recursive: true });
|
|
2068
|
+
writeFileSync(join(iprofile, "package.json"), JSON.stringify({
|
|
2069
|
+
dependencies: { good: "1.0.0" },
|
|
2070
|
+
dsh: { profile: { bundles: ["good"] } },
|
|
2071
|
+
}));
|
|
2072
|
+
writeFileSync(join(iprofile, "cordis.patch.yml"), "[]\n");
|
|
2073
|
+
writeFileSync(join(goodDir, "package.json"), JSON.stringify({ name: "good", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }));
|
|
2074
|
+
writeFileSync(join(goodDir, "cordis.patch.yml"), "- insert:\n - id: good-row\n name: good\n");
|
|
2075
|
+
const ipending = createProfileSnapshot(iprofile, { spec: "good" });
|
|
2076
|
+
markPendingSnapshot(ipending, { spec: "good@1.0.0", preflight: { candidate: { name: "good", version: "1.0.0", kind: "bundle" } } });
|
|
2077
|
+
if (!validateInstalledProfile(iprofile).ok) throw new Error("interrupt fixture prerequisite: the profile must pass static validation");
|
|
2078
|
+
return { ihome, iprofile };
|
|
2079
|
+
};
|
|
2080
|
+
|
|
2081
|
+
// interrupted: exit 130, marker stays pending, nothing rolls back
|
|
2082
|
+
const { ihome } = mkInterruptProfile("interrupt-home");
|
|
2083
|
+
const interruptedWatcher = createInterruptWatcher({ forceGraceMs: 60000 });
|
|
2084
|
+
process.emit("SIGINT");
|
|
2085
|
+
const interruptedCode = await cmdLaunch({
|
|
2086
|
+
profile: "web",
|
|
2087
|
+
home: ihome,
|
|
2088
|
+
graceMs: 4000,
|
|
2089
|
+
commandArgv: [process.execPath, "-e", "process.exit(1)"],
|
|
2090
|
+
interrupt: interruptedWatcher,
|
|
2091
|
+
});
|
|
2092
|
+
interruptedWatcher.unlisten();
|
|
2093
|
+
if (interruptedCode !== 130) throw new Error(`an interrupted run must exit 130, got ${interruptedCode}`);
|
|
2094
|
+
if (readPendingSnapshot(join(ihome, "profiles", "web")) === undefined) throw new Error("an interrupted run must leave the pending marker for the next launch");
|
|
2095
|
+
if (!existsSync(join(ihome, "profiles", "web", "node_modules", "good", "package.json"))) throw new Error("an interrupted run must not roll the install back");
|
|
2096
|
+
|
|
2097
|
+
// The same crash WITHOUT an interrupt must not read as 130: offline the
|
|
2098
|
+
// rollback-and-restart may legitimately fail (no pnpm to reconcile
|
|
2099
|
+
// node_modules), which throws — both spellings prove the interrupt
|
|
2100
|
+
// branch was NOT taken. A marker-free run pins the pure exit-code split.
|
|
2101
|
+
const plain = mkInterruptProfile("interrupt-control-home");
|
|
2102
|
+
let plainReturned;
|
|
2103
|
+
let plainCode;
|
|
2104
|
+
try {
|
|
2105
|
+
plainCode = await cmdLaunch({
|
|
2106
|
+
profile: "web",
|
|
2107
|
+
home: plain.ihome,
|
|
2108
|
+
graceMs: 4000,
|
|
2109
|
+
commandArgv: [process.execPath, "-e", "process.exit(1)"],
|
|
2110
|
+
});
|
|
2111
|
+
plainReturned = true;
|
|
2112
|
+
} catch {
|
|
2113
|
+
plainReturned = false; // offline rollback limits, not the pin under test
|
|
2114
|
+
}
|
|
2115
|
+
if (plainReturned && plainCode === 130) throw new Error("a genuine grace-window crash must not report the interrupt convention 130");
|
|
2116
|
+
|
|
2117
|
+
const bareHome = join(root, "interrupt-bare-home");
|
|
2118
|
+
const bareProfile = join(bareHome, "profiles", "web");
|
|
2119
|
+
mkdirSync(bareProfile, { recursive: true });
|
|
2120
|
+
writeFileSync(join(bareProfile, "package.json"), JSON.stringify({ dependencies: {} }));
|
|
2121
|
+
writeFileSync(join(bareProfile, "cordis.patch.yml"), "[]\n");
|
|
2122
|
+
const barePlain = await cmdLaunch({
|
|
2123
|
+
profile: "web",
|
|
2124
|
+
home: bareHome,
|
|
2125
|
+
commandArgv: [process.execPath, "-e", "process.exit(1)"],
|
|
2126
|
+
});
|
|
2127
|
+
if (barePlain !== 1) throw new Error(`a plain crash without an interrupt keeps the child's code, got ${barePlain}`);
|
|
2128
|
+
const bareWatcher = createInterruptWatcher({ forceGraceMs: 60000 });
|
|
2129
|
+
process.emit("SIGINT");
|
|
2130
|
+
const bareInterrupted = await cmdLaunch({
|
|
2131
|
+
profile: "web",
|
|
2132
|
+
home: bareHome,
|
|
2133
|
+
commandArgv: [process.execPath, "-e", "process.exit(1)"],
|
|
2134
|
+
interrupt: bareWatcher,
|
|
2135
|
+
});
|
|
2136
|
+
bareWatcher.unlisten();
|
|
2137
|
+
if (bareInterrupted !== 130) throw new Error(`an interrupted plain run reports 130, got ${bareInterrupted}`);
|
|
2138
|
+
|
|
2139
|
+
// interrupted while waiting for the outgoing pid: refuse, don't outwait
|
|
2140
|
+
const waitingWatcher = createInterruptWatcher({ forceGraceMs: 60000 });
|
|
2141
|
+
process.emit("SIGINT");
|
|
2142
|
+
let cancelled = false;
|
|
2143
|
+
try {
|
|
2144
|
+
await cmdLaunch({
|
|
2145
|
+
profile: "web",
|
|
2146
|
+
home: plain.ihome,
|
|
2147
|
+
commandArgv: [process.execPath, "-e", "process.exit(0)"],
|
|
2148
|
+
awaitExitPid: 999999,
|
|
2149
|
+
interrupt: waitingWatcher,
|
|
2150
|
+
_waitForExit: async (pid, timeoutMs, pollMs, shouldAbort) => (shouldAbort?.() === true ? false : true),
|
|
2151
|
+
});
|
|
2152
|
+
} catch (error) {
|
|
2153
|
+
cancelled = /interrupted while waiting/.test(error.message);
|
|
2154
|
+
}
|
|
2155
|
+
waitingWatcher.unlisten();
|
|
2156
|
+
if (!cancelled) throw new Error("a Ctrl+C during the await-exit wait must cancel the launch with a clear message");
|
|
2157
|
+
}
|
|
2158
|
+
|
|
1300
2159
|
// quoteCmdArg: strict MSVCRT/CommandLineToArgvW quoting; cmd metacharacters
|
|
1301
2160
|
// are refused rather than escaped.
|
|
1302
2161
|
{
|