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
|
@@ -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
|
-
<
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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 ===
|
|
661
|
+
let foundScreen = newScreens.find(x => x.screenName === launchScreenName);
|
|
659
662
|
if (!foundScreen) {
|
|
660
|
-
console.error(`Just created screen ${
|
|
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 ${
|
|
666
|
-
throw new Error(`${logs}\n\nService ${magenta(
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
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);
|