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