querysub 0.560.0 → 0.562.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.560.0",
3
+ "version": "0.562.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -17,6 +17,7 @@ const DEAD_COLOR = { h: 0, s: 0, l: 92 };
17
17
  const OUTPUT_BUFFER_LIMIT = 1_000_000;
18
18
  const OUTPUT_BUFFER_KEPT = 100_000;
19
19
  const OUTPUT_MAX_HEIGHT = "40vh";
20
+ const DETAIL_FONT_SIZE = 12;
20
21
  const ANSI_SATURATION = 70;
21
22
  const ANSI_LIGHTNESS = 70;
22
23
 
@@ -113,6 +114,17 @@ class ProcessRow extends qreact.Component<{ record: ProcessRecord; machineNodeId
113
114
  {this.state.watching && "Hide output" || "Watch output"}
114
115
  </Button>
115
116
  </div>
117
+ {/* The ref and the command are both long, so they get their own line rather than pushing the summary around */}
118
+ <div className={css.hbox(10).wrap.fontSize(DETAIL_FONT_SIZE).colorhsl(0, 0, 30)}>
119
+ {record.parameters.gitRef && <div className={css.hbox(4)}>
120
+ <span className={css.colorhsl(0, 0, 45)}>ref</span>
121
+ <span className={css.fontFamily("monospace")}>{record.parameters.gitRef}</span>
122
+ </div>}
123
+ {record.parameters.command && <div className={css.hbox(4).flexGrow(1)}>
124
+ <span className={css.colorhsl(0, 0, 45)}>command</span>
125
+ <span className={css.fontFamily("monospace").whiteSpace("pre-wrap")}>{record.parameters.command}</span>
126
+ </div>}
127
+ </div>
116
128
  {this.state.watching && <ProcessOutput record={record} machineNodeId={this.props.machineNodeId} />}
117
129
  </div>;
118
130
  }
@@ -346,6 +346,8 @@ export class ServiceDetailPage extends qreact.Component {
346
346
  {/* Machine Status */}
347
347
  {config.parameters.deploy && <div className={css.vbox(8).fillWidth}>
348
348
  <h3>Deployed Machines ({machineIds.length})</h3>
349
+ {/* The machines are notified on every config change and poll as a fallback, so forcing a resync had no use in practice. Kept in case that changes.
350
+ <ResyncMachinesButton applyNodeIds={machineStatuses.map(x => x.machineInfo?.applyNodeId || "")} /> */}
349
351
  <div className={css.vbox(4).fillWidth}>
350
352
  {machineStatuses.map(({ machineId, variables, machineInfo, serviceInfo, isMachineDead, hasError, isDisabled, index }) => {
351
353
  if (!machineInfo) return <div key={machineId}>Loading {machineId}...</div>;
@@ -10,6 +10,7 @@ import { sort, timeInMinute, timeInSecond } from "socket-function/src/misc";
10
10
  import { cache } from "socket-function/src/caching";
11
11
  import { Anchor } from "../../library-components/ATag";
12
12
  import { isPublic } from "../../config";
13
+ // ResyncMachinesButton is still exported from deployButtons, for whenever forcing a resync becomes useful
13
14
  import { PendingDeployInfo, UpdateButtons, UpdateServiceButtons } from "./deployButtons";
14
15
  import { isDefined } from "../../misc";
15
16
  import { formatDateJSX } from "../../misc/formatJSX";
@@ -43,6 +44,10 @@ export class ServicesListPage extends qreact.Component {
43
44
  return <div className={css.vbox(16)}>
44
45
  <div className={css.hbox(12).wrap}>
45
46
  <h2 className={css.flexGrow(1)}>Services</h2>
47
+ {/* The machines are notified on every config change and poll as a fallback, so forcing a resync had no use in practice. Kept in case that changes.
48
+ <ResyncMachinesButton applyNodeIds={(machineConfigList || []).map(x =>
49
+ controller.getMachineInfo(x.machineId)?.applyNodeId || ""
50
+ )} /> */}
46
51
  <button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100)}
47
52
  onClick={() => {
48
53
  Querysub.onCommitFinished(async () => {
@@ -19,6 +19,37 @@ export function bigEmoji(emoji: string, topOffset = 0) {
19
19
  return <span className={css.fontSize(26).marginTop(topOffset)}>{emoji}</span>;
20
20
  }
21
21
 
22
+ /** Makes the machines re-read the service configs immediately, instead of waiting out their fallback poll. Useful when a config was changed without the machines being notified, or a notification was missed. */
23
+ export class ResyncMachinesButton extends qreact.Component<{ applyNodeIds: string[] }> {
24
+ state = t.state({
25
+ isResyncing: t.type(false),
26
+ });
27
+ render() {
28
+ let applyNodeIds = unique(this.props.applyNodeIds.filter(x => x));
29
+ if (!applyNodeIds.length) return undefined;
30
+ return <button
31
+ className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(200, 70, 90)}
32
+ disabled={this.state.isResyncing}
33
+ title="Tells each machine's apply code to re-read the service configs now"
34
+ onClick={() => {
35
+ this.state.isResyncing = true;
36
+ let controller = MachineController(SocketFunction.browserNodeId());
37
+ Querysub.onCommitFinished(async () => {
38
+ try {
39
+ await Promise.all(applyNodeIds.map(nodeId => controller.resyncMachine.promise({ nodeId })));
40
+ } finally {
41
+ Querysub.commit(() => {
42
+ this.state.isResyncing = false;
43
+ });
44
+ }
45
+ });
46
+ }}
47
+ >
48
+ {this.state.isResyncing && "⏳ Resyncing..." || `🔄 Resync ${applyNodeIds.length} machine${applyNodeIds.length === 1 && "" || "s"}`}
49
+ </button>;
50
+ }
51
+ }
52
+
22
53
  export class RenderGitRefInfo extends qreact.Component<{
23
54
  gitRef: string;
24
55
  isQuerysub?: boolean;
@@ -765,95 +765,6 @@ async function getPPID(pid: string) {
765
765
  }
766
766
  }
767
767
 
768
- // Node prints this when the process has exited but the V8 inspector is still
769
- // holding it open waiting for a debugger client to detach. If a debugger client
770
- // drops uncleanly the process can sit on this forever, holding the screen and
771
- // preventing the supervisor from spawning a fresh instance. The watcher below
772
- // polls each running process's log for this phrase and sends Ctrl+C to break it out.
773
- const DEBUGGER_DISCONNECT_MESSAGE = "Waiting for the debugger to disconnect...";
774
-
775
- // To avoid false positives from app code that happens to log a string
776
- // containing the phrase, the wedge is only reported when:
777
- // 1. the phrase is on its own line (start-of-string or after a newline), AND
778
- // 2. nothing else has been logged after it (only trailing whitespace/EOF).
779
- // In a real wedge, this line is the very last thing Node writes before going
780
- // silent — so any later output rules out the wedge interpretation.
781
- const DEBUGGER_DISCONNECT_TAIL_PATTERN = /(?:^|\r?\n)Waiting for the debugger to disconnect\.\.\.\s*$/;
782
-
783
- // How often the wedge watcher scans every running process's log.
784
- const DEBUGGER_WEDGE_POLL_INTERVAL = timeInSecond * 15;
785
- // How much of the tail of the log to read on each scan. The phrase is short
786
- // and appears near the very end of the log when a process is stuck, so a few
787
- // KB is plenty and keeps scans cheap.
788
- const DEBUGGER_WEDGE_TAIL_BYTES = 8 * 1024;
789
- // Suppress repeated Ctrl+C bursts to the same screen while it tears down; if
790
- // the first signal didn't break the wait, give it this long before we try again.
791
- const DEBUGGER_WEDGE_RESIGNAL_DELAY = timeInMinute;
792
- // After a first match, wait this long and re-check before signaling. If the
793
- // process is still alive and just happened to log a message ending in the
794
- // phrase, more output will appear in the meantime and the second check fails.
795
- const DEBUGGER_WEDGE_RECHECK_DELAY = timeInSecond * 3;
796
-
797
- // screenName -> last time we sent Ctrl+C because of a debugger wedge.
798
- const lastDebuggerWedgeSignal = new Map<string, number>();
799
-
800
- async function readPipeFileTail(pipeFile: string, maxBytes: number): Promise<string> {
801
- let handle: fs.promises.FileHandle | undefined;
802
- try {
803
- handle = await fs.promises.open(pipeFile, "r");
804
- let stat = await handle.stat();
805
- let start = Math.max(0, stat.size - maxBytes);
806
- let length = stat.size - start;
807
- if (length <= 0) return "";
808
- let buf = Buffer.alloc(length);
809
- await handle.read(buf, 0, length, start);
810
- return buf.toString("utf8");
811
- } catch {
812
- // File doesn't exist yet, or got truncated mid-read — treat as empty.
813
- return "";
814
- } finally {
815
- if (handle) {
816
- try {
817
- await handle.close();
818
- } catch {
819
- // ignore
820
- }
821
- }
822
- }
823
- }
824
-
825
- async function unwedgeStuckDebuggerScreens(): Promise<void> {
826
- let prefix = getTmuxPrefix();
827
- let screens = await getScreenState(false);
828
- let running = (await listProcessRecords()).filter(x => x.deadTime === undefined);
829
- for (let { screenName } of screens) {
830
- let record = running.find(x => x.screenName === screenName);
831
- if (!record) continue;
832
- let pipeFile = getProcessLogPath(record.folder, record.launchId);
833
- let tail1 = await readPipeFileTail(pipeFile, DEBUGGER_WEDGE_TAIL_BYTES);
834
- if (!DEBUGGER_DISCONNECT_TAIL_PATTERN.test(tail1)) continue;
835
-
836
- // Confirm the process is actually wedged and not just briefly idle by
837
- // re-checking after a short delay. A live process will write more
838
- // output in this window, which moves the phrase away from the end
839
- // and fails the second match.
840
- await delay(DEBUGGER_WEDGE_RECHECK_DELAY);
841
- let tail2 = await readPipeFileTail(pipeFile, DEBUGGER_WEDGE_TAIL_BYTES);
842
- if (!DEBUGGER_DISCONNECT_TAIL_PATTERN.test(tail2)) continue;
843
-
844
- let last = lastDebuggerWedgeSignal.get(screenName) ?? 0;
845
- if (Date.now() - last < DEBUGGER_WEDGE_RESIGNAL_DELAY) continue;
846
- lastDebuggerWedgeSignal.set(screenName, Date.now());
847
-
848
- console.warn(red(`Detected stuck "${DEBUGGER_DISCONNECT_MESSAGE}" in ${screenName} (confirmed across two checks ${DEBUGGER_WEDGE_RECHECK_DELAY}ms apart); sending Ctrl+C to unblock it.`));
849
- try {
850
- await runPromise(`${prefix}tmux send-keys -t ${screenName} 'C-c' Enter`);
851
- } catch (e: any) {
852
- console.warn(`Failed to send Ctrl+C to ${screenName}: ${e.stack ?? e}`);
853
- }
854
- }
855
- }
856
-
857
768
  export async function machineApplyMain() {
858
769
  let parentPID = process.argv[2];
859
770
  // Wait for the console to get shimmed
@@ -882,7 +793,6 @@ export async function machineApplyMain() {
882
793
  await Querysub.hostService("machine-apply");
883
794
  onServiceConfigChange(resyncServices);
884
795
 
885
- runInfinitePoll(DEBUGGER_WEDGE_POLL_INTERVAL, unwedgeStuckDebuggerScreens);
886
796
 
887
797
  runInfinitePoll(timeInMinute * 3, async () => {
888
798
  //console.log(magenta(`Quick outdated check at ${new Date().toISOString()}`));
@@ -73,6 +73,10 @@ export const OnServiceChange = SocketFunction.register(
73
73
 
74
74
  class MachineControllerBase {
75
75
  // NOTE: We don't need to worry about escaping commands here. YES, the user CAN inject code into the key. But this system is literally for running arbitrary commands, so they could just write a serviceConfig and run anything they want, on all the machines...
76
+ /** Tells a machine's apply code to re-read the service configs now, rather than waiting for its 15 minute fallback poll. Forwarded, as clients can't reach the machines directly. */
77
+ public async resyncMachine(config: { nodeId: string }): Promise<void> {
78
+ await OnServiceChange.nodes[config.nodeId].onServiceConfigChange();
79
+ }
76
80
  /** Every process this machine has a record of, running or dead. */
77
81
  public async listProcesses(): Promise<ProcessRecord[]> {
78
82
  return await listProcessRecords();
@@ -143,6 +147,7 @@ export const MachineController = getSyncedController(SocketFunction.register(
143
147
  "machine-controller-c3157d4a-580c-4e76-9dc9-072dd92e70af",
144
148
  () => new MachineControllerBase(),
145
149
  () => ({
150
+ resyncMachine: {},
146
151
  listProcesses: {},
147
152
  streamProcessOutput: {},
148
153
  listOtherProcesses: {},
@@ -5,6 +5,8 @@ import { spawn, ChildProcess } from "child_process";
5
5
  import { lazy } from "socket-function/src/caching";
6
6
  import { measureWrap } from "socket-function/src/profiling/measure";
7
7
  import { delay, runInSerial } from "socket-function/src/batching";
8
+ import { timeInSecond } from "socket-function/src/misc";
9
+ import { formatTime } from "socket-function/src/formatting/format";
8
10
  import { red, green } from "socket-function/src/formatting/logColors";
9
11
  import { runPromise } from "../functional/runCommand";
10
12
  import { forceRemoveNode } from "../-f-node-discovery/NodeDiscovery";
@@ -15,6 +17,9 @@ import { ProcessRecord, createLaunchId, getLogPipeScript, getLogTailScript, getP
15
17
 
16
18
  // Running, inspecting and killing the tmux screens services run in. This layer only knows "here is a configuration, run it" - which version should be running when is the deploy logic's problem.
17
19
 
20
+ // How long a process gets between being told to shut down and having its session killed. It is a courtesy, not a negotiation - nothing waits on the process actually exiting.
21
+ const SHUTDOWN_GRACE_TIME = timeInSecond * 15;
22
+
18
23
  const SCREEN_SUFFIX = "-dply";
19
24
  export function getScreenName(config: { serviceKey: string; index: number }): string {
20
25
  return `${config.serviceKey}-${config.index}${SCREEN_SUFFIX}`.replace(/[^a-zA-Z0-9\-_]/g, "_");
@@ -178,47 +183,16 @@ export const runScreenCommand = measureWrap(async function runScreenCommand(conf
178
183
  let prefix = getTmuxPrefix();
179
184
  let screenName = config.screenName;
180
185
 
181
- try {
182
- // Throw if it already exists
183
- await runPromise(`${prefix}tmux new -s ${screenName} -d`);
184
- } catch { }
185
- await runPromise(`${prefix}tmux send-keys -t ${screenName} 'echo "Updating running command at ${new Date().toISOString()}"' Enter`);
186
- await runPromise(`${prefix}tmux send-keys -t ${screenName} 'C-c' Enter`);
187
- await delay(1000);
188
-
189
-
190
- let screens = await getScreenState();
191
- let screen = screens.find(x => x.screenName === screenName);
192
- let pid = screen?.pid;
193
- if (pid && await isScreenRunningProcess(pid)) {
194
- // It doesn't want to die. Wait longer, but it it just won't die, kill the screen
195
- console.warn(`Screen ${screenName} is not dying, giving it another 30 seconds`);
196
- for (let i = 0; i < 6; i++) {
197
- await delay(5);
198
- if (!await isScreenRunningProcess(pid)) {
199
- break;
200
- }
201
- }
202
- if (pid && await isScreenRunningProcess(pid)) {
203
- console.warn(`Screen ${screenName} is still running, killing it forcefully`);
204
- await killScreen({ screenName });
205
- if (pid && await isScreenRunningProcess(pid)) {
206
- console.error(`I don't know what happened. The screen won't die. We can't do much else, I guess we'll just ignore it...`);
207
- } else {
208
- // Nested, to create the screen again.
209
- return await runScreenCommand({
210
- screenName,
211
- command: config.command,
212
- folder: config.folder,
213
- record: config.record,
214
- });
215
- }
216
- }
186
+ // A launch is ALWAYS a new session, never a new command in the old one. Reusing the session kept the pane, and the pane's pid and start time are what identify the process - so the "new" process was indistinguishable from the one before it and appended to its log. Every path goes through this, including an immediate zero-overlap deploy: a special case for that would be a second flow to get wrong.
187
+ if ((await getScreenState(false)).some(x => x.screenName === screenName)) {
188
+ await killScreen({ screenName, skipNodeIdRemoval: true });
217
189
  }
190
+ await runPromise(`${prefix}tmux new -s ${screenName} -d`);
191
+
218
192
  await removeOldNodeId(screenName);
219
193
  let folder = config.folder || os.homedir() + "/" + SERVICE_FOLDER + screenName + "/";
220
194
 
221
- // The pane we are about to run the command in identifies the process: its pid, and the start time the OS reports for that pid. Both can be re-checked against the machine later, so nothing downstream has to trust our bookkeeping about what is still running.
195
+ // The pane we are about to run the command in identifies the process: its pid, and the start time the OS reports for that pid. Both can be re-checked against the machine later, so nothing downstream has to trust our bookkeeping about what is still running. The session was just created, so this pid is always new.
222
196
  let panePid = (await getScreenState(false)).find(x => x.screenName === screenName)?.pid;
223
197
  if (!panePid) {
224
198
  throw new Error(`Screen ${screenName} does not exist after creating it, so there is no process to run the command in`);
@@ -292,17 +266,11 @@ export const killScreen = measureWrap(async function killScreen(config: {
292
266
  // During a takeover the folder's nodeId file already belongs to the NEW process, so the old screen's kill must not remove it
293
267
  skipNodeIdRemoval?: boolean;
294
268
  }) {
295
- console.log(red(`Killing screen ${config.screenName}`));
269
+ console.log(red(`Killing screen ${config.screenName} (Ctrl+C, then killing the session in ${formatTime(SHUTDOWN_GRACE_TIME)})`));
296
270
  let prefix = getTmuxPrefix();
297
- // Try ctrl+c a few times first
298
- let pid = (await getScreenState(false)).find(x => x.screenName === config.screenName)?.pid;
299
- for (let i = 0; i < 5; i++) {
300
- if (!pid || !await isScreenRunningProcess(pid)) {
301
- break;
302
- }
303
- await runPromise(`${prefix}tmux send-keys -t ${config.screenName} 'C-c' Enter`);
304
- await delay(5000);
305
- }
271
+ // Purely a notice to the process that it is going away, so it can wind down. We never check whether it worked and we never reuse the screen - the session is killed either way.
272
+ await runPromise(`${prefix}tmux send-keys -t ${config.screenName} 'C-c' Enter`);
273
+ await delay(SHUTDOWN_GRACE_TIME);
306
274
  await runPromise(`${prefix}tmux kill-session -t ${config.screenName}`);
307
275
  if (!config.skipNodeIdRemoval) {
308
276
  await removeOldNodeId(config.screenName);