querysub 0.561.0 → 0.563.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.561.0",
3
+ "version": "0.563.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",
@@ -13,7 +13,6 @@ import { InputLabel } from "../../library-components/InputLabel";
13
13
  import { Button } from "../../library-components/Button";
14
14
  import { isDefined } from "../../misc";
15
15
  import { ProcessesView } from "./ProcessesView";
16
- import { ResyncMachinesButton } from "./deployButtons";
17
16
  import { getPathStr2 } from "../../path";
18
17
  import { ATag, Anchor } from "../../library-components/ATag";
19
18
  import { ScrollOnMount } from "../../library-components/ScrollOnMount";
@@ -346,10 +345,9 @@ export class ServiceDetailPage extends qreact.Component {
346
345
  </div>
347
346
  {/* Machine Status */}
348
347
  {config.parameters.deploy && <div className={css.vbox(8).fillWidth}>
349
- <div className={css.hbox(12).wrap.alignItems("center")}>
350
- <h3 className={css.flexGrow(1)}>Deployed Machines ({machineIds.length})</h3>
351
- <ResyncMachinesButton applyNodeIds={machineStatuses.map(x => x.machineInfo?.applyNodeId || "")} />
352
- </div>
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 || "")} /> */}
353
351
  <div className={css.vbox(4).fillWidth}>
354
352
  {machineStatuses.map(({ machineId, variables, machineInfo, serviceInfo, isMachineDead, hasError, isDisabled, index }) => {
355
353
  if (!machineInfo) return <div key={machineId}>Loading {machineId}...</div>;
@@ -10,7 +10,8 @@ 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
- import { PendingDeployInfo, ResyncMachinesButton, UpdateButtons, UpdateServiceButtons } from "./deployButtons";
13
+ // ResyncMachinesButton is still exported from deployButtons, for whenever forcing a resync becomes useful
14
+ import { PendingDeployInfo, UpdateButtons, UpdateServiceButtons } from "./deployButtons";
14
15
  import { isDefined } from "../../misc";
15
16
  import { formatDateJSX } from "../../misc/formatJSX";
16
17
  import { Tools } from "./Tools";
@@ -43,9 +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.
46
48
  <ResyncMachinesButton applyNodeIds={(machineConfigList || []).map(x =>
47
49
  controller.getMachineInfo(x.machineId)?.applyNodeId || ""
48
- )} />
50
+ )} /> */}
49
51
  <button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100)}
50
52
  onClick={() => {
51
53
  Querysub.onCommitFinished(async () => {
@@ -642,8 +642,11 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
642
642
  time: Date.now(),
643
643
  });
644
644
 
645
+ // Every launch goes through the future screen and a rename, releases and plain restarts alike. The new process is up before the old one is touched, so nothing waits on a shutdown, and the pane it runs in is always new - which is what makes it a distinct process with its own log.
646
+ let launchScreenName = getFutureScreenName(screenName);
645
647
  let launchId = await runScreenCommand({
646
- screenName,
648
+ screenName: launchScreenName,
649
+ folder,
647
650
  command: instanceParameters.command,
648
651
  record: {
649
652
  serviceId: config.serviceId,
@@ -655,16 +658,21 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
655
658
  });
656
659
  await delay(2000);
657
660
  let newScreens = await getScreenState(false);
658
- let foundScreen = newScreens.find(x => x.screenName === screenName);
661
+ let foundScreen = newScreens.find(x => x.screenName === launchScreenName);
659
662
  if (!foundScreen) {
660
- console.error(`Just created screen ${screenName}, but it's not in the list of screens!`);
663
+ console.error(`Just created screen ${launchScreenName}, but it's not in the list of screens!`);
661
664
  }
662
665
  let isRunning = foundScreen && await isScreenRunningProcess(foundScreen.pid);
663
666
  if (!isRunning) {
664
667
  let prefix = getTmuxPrefix();
665
- let logs = await runPromise(`${prefix}tmux capture-pane -t ${screenName} -p`);
666
- throw new Error(`${logs}\n\nService ${magenta(screenName)} is not running after starting. Trying again in ${formatTime(MACHINE_RESYNC_INTERVAL)}, or on next change. Last logs above.`);
668
+ let logs = await runPromise(`${prefix}tmux capture-pane -t ${launchScreenName} -p`);
669
+ throw new Error(`${logs}\n\nService ${magenta(launchScreenName)} is not running after starting. Trying again in ${formatTime(MACHINE_RESYNC_INTERVAL)}, or on next change. Last logs above.`);
670
+ }
671
+ // Up and verified, so the old process can go now
672
+ if (foundScreen) {
673
+ screenStateMap.set(launchScreenName, foundScreen);
667
674
  }
675
+ await takeoverFutureScreen({ canonicalScreenName: screenName, screenNamesUsed, screenStateMap });
668
676
  console.log(green(`Service ${magenta(screenName)} is verified to be running after starting (process ${launchId})`));
669
677
  await syncTimeline(foundScreen?.pid);
670
678
 
@@ -765,95 +773,6 @@ async function getPPID(pid: string) {
765
773
  }
766
774
  }
767
775
 
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
776
  export async function machineApplyMain() {
858
777
  let parentPID = process.argv[2];
859
778
  // Wait for the console to get shimmed
@@ -882,7 +801,6 @@ export async function machineApplyMain() {
882
801
  await Querysub.hostService("machine-apply");
883
802
  onServiceConfigChange(resyncServices);
884
803
 
885
- runInfinitePoll(DEBUGGER_WEDGE_POLL_INTERVAL, unwedgeStuckDebuggerScreens);
886
804
 
887
805
  runInfinitePoll(timeInMinute * 3, async () => {
888
806
  //console.log(magenta(`Quick outdated check at ${new Date().toISOString()}`));
@@ -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,18 @@ 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 brand new session. Callers launch into the "-future" name and rename it over the canonical one once the new process is up, so starting a replacement never waits on the old process going away. Nothing is killed here.
187
+ // A leftover session under this name is from a launch that died before its takeover, so it is dead weight rather than something we are replacing.
188
+ if ((await getScreenState(false)).some(x => x.screenName === screenName)) {
189
+ console.log(red(`Removing the leftover session ${screenName} from a launch that never completed`));
190
+ await runPromise(`${prefix}tmux kill-session -t ${screenName}`);
217
191
  }
192
+ await runPromise(`${prefix}tmux new -s ${screenName} -d`);
193
+
218
194
  await removeOldNodeId(screenName);
219
195
  let folder = config.folder || os.homedir() + "/" + SERVICE_FOLDER + screenName + "/";
220
196
 
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.
197
+ // 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
198
  let panePid = (await getScreenState(false)).find(x => x.screenName === screenName)?.pid;
223
199
  if (!panePid) {
224
200
  throw new Error(`Screen ${screenName} does not exist after creating it, so there is no process to run the command in`);
@@ -292,17 +268,11 @@ export const killScreen = measureWrap(async function killScreen(config: {
292
268
  // 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
269
  skipNodeIdRemoval?: boolean;
294
270
  }) {
295
- console.log(red(`Killing screen ${config.screenName}`));
271
+ console.log(red(`Killing screen ${config.screenName} (Ctrl+C, then killing the session in ${formatTime(SHUTDOWN_GRACE_TIME)})`));
296
272
  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
- }
273
+ // 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.
274
+ await runPromise(`${prefix}tmux send-keys -t ${config.screenName} 'C-c' Enter`);
275
+ await delay(SHUTDOWN_GRACE_TIME);
306
276
  await runPromise(`${prefix}tmux kill-session -t ${config.screenName}`);
307
277
  if (!config.skipNodeIdRemoval) {
308
278
  await removeOldNodeId(config.screenName);