querysub 0.555.0 → 0.557.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 +2 -2
- package/src/-0-hooks/hooks.ts +2 -2
- package/src/0-path-value-core/PathWatcher.ts +6 -0
- package/src/deployManager/components/ProcessesView.tsx +169 -0
- package/src/deployManager/components/ServiceDetailPage.tsx +9 -124
- package/src/deployManager/machineApplyMainCode.ts +0 -0
- package/src/deployManager/machineController.ts +40 -37
- package/src/deployManager/processLogs.ts +171 -0
- package/src/deployManager/processManager.ts +369 -0
- package/src/diagnostics/trackResources.ts +4 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "querysub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.557.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",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6",
|
|
72
72
|
"pako": "^2.1.0",
|
|
73
73
|
"peggy": "^5.0.6",
|
|
74
|
-
"sliftutils": "^1.7.
|
|
74
|
+
"sliftutils": "^1.7.40",
|
|
75
75
|
"socket-function": "^1.2.26",
|
|
76
76
|
"terser": "^5.31.0",
|
|
77
77
|
"typenode": "^6.6.1",
|
package/src/-0-hooks/hooks.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { MaybePromise } from "socket-function/src/types";
|
|
2
|
-
import { CallInterceptor } from "../3-path-functions/PathFunctionHelpers";
|
|
1
|
+
import type { MaybePromise } from "socket-function/src/types";
|
|
2
|
+
import type { CallInterceptor } from "../3-path-functions/PathFunctionHelpers";
|
|
3
3
|
import type { EdgeNodeConfig } from "../4-deploy/edgeNodes";
|
|
4
4
|
import type { ExtraMetadata } from "../5-diagnostics/nodeMetadata";
|
|
5
5
|
|
|
@@ -50,6 +50,12 @@ function incrementWatcherSequence() {
|
|
|
50
50
|
});
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
if (!registerResource) {
|
|
54
|
+
debugger;
|
|
55
|
+
require("debugbreak")(2);
|
|
56
|
+
debugger;
|
|
57
|
+
}
|
|
58
|
+
|
|
53
59
|
// WATCH CASES
|
|
54
60
|
// 1) getOwnNodeId() is used to trigger pathValueClientWatcher (no one else should be using it)
|
|
55
61
|
// 2) Use other nodes to immediately proxy to the other nodes
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { SocketFunction } from "socket-function/SocketFunction";
|
|
2
|
+
import { qreact } from "../../4-dom/qreact";
|
|
3
|
+
import { css } from "typesafecss";
|
|
4
|
+
import { t } from "../../2-proxy/schema2";
|
|
5
|
+
import { Querysub } from "../../4-querysub/Querysub";
|
|
6
|
+
import { formatTime, formatDateTimeDetailed } from "socket-function/src/formatting/format";
|
|
7
|
+
import { sort, timeInSecond, nextId } from "socket-function/src/misc";
|
|
8
|
+
import { MachineController, watchProcessOutput, stopWatchingProcessOutput } from "../machineController";
|
|
9
|
+
import type { ProcessRecord } from "../processLogs";
|
|
10
|
+
import { Button } from "../../library-components/Button";
|
|
11
|
+
import { parseAnsiColors } from "../../diagnostics/logs/ansiFormat";
|
|
12
|
+
|
|
13
|
+
module.hotreload = true;
|
|
14
|
+
|
|
15
|
+
const RUNNING_COLOR = { h: 130, s: 55, l: 88 };
|
|
16
|
+
const DEAD_COLOR = { h: 0, s: 0, l: 92 };
|
|
17
|
+
const OUTPUT_BUFFER_LIMIT = 1_000_000;
|
|
18
|
+
const OUTPUT_BUFFER_KEPT = 100_000;
|
|
19
|
+
const OUTPUT_MAX_HEIGHT = "40vh";
|
|
20
|
+
|
|
21
|
+
/** One process's live output. Mounting starts the stream, unmounting stops it, so the watch lifetime is exactly the time it is on screen. */
|
|
22
|
+
class ProcessOutput extends qreact.Component<{ record: ProcessRecord; machineNodeId: string }> {
|
|
23
|
+
state = t.state({
|
|
24
|
+
data: t.type(""),
|
|
25
|
+
});
|
|
26
|
+
private callbackId = nextId();
|
|
27
|
+
// Unmounting before the watch finishes registering would otherwise stop a callback that gets added right after, leaving it streaming into a component that is gone
|
|
28
|
+
private watching: Promise<void> = Promise.resolve();
|
|
29
|
+
private unmounted = false;
|
|
30
|
+
componentDidMount() {
|
|
31
|
+
// Props are synchronized state, so the plain values the async work needs are read here
|
|
32
|
+
let { folder, launchId } = this.props.record;
|
|
33
|
+
let nodeId = this.props.machineNodeId;
|
|
34
|
+
let callbackId = this.callbackId;
|
|
35
|
+
this.watching = (async () => {
|
|
36
|
+
await watchProcessOutput({
|
|
37
|
+
nodeId,
|
|
38
|
+
folder,
|
|
39
|
+
launchId,
|
|
40
|
+
callbackId,
|
|
41
|
+
onData: async (data: string) => {
|
|
42
|
+
Querysub.commit(() => {
|
|
43
|
+
let full = this.state.data + data;
|
|
44
|
+
// Trimmed well below the limit, so trimming is rare - each trim jumps the scroll position
|
|
45
|
+
if (full.length > OUTPUT_BUFFER_LIMIT) {
|
|
46
|
+
full = full.slice(-OUTPUT_BUFFER_KEPT);
|
|
47
|
+
}
|
|
48
|
+
this.state.data = full;
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
// Unmounted while we were registering, so stop the watch we just started
|
|
53
|
+
if (this.unmounted) {
|
|
54
|
+
await stopWatchingProcessOutput({ callbackId });
|
|
55
|
+
}
|
|
56
|
+
})();
|
|
57
|
+
}
|
|
58
|
+
componentWillUnmount() {
|
|
59
|
+
this.unmounted = true;
|
|
60
|
+
let callbackId = this.callbackId;
|
|
61
|
+
let watching = this.watching;
|
|
62
|
+
Querysub.onCommitFinished(async () => {
|
|
63
|
+
await watching;
|
|
64
|
+
await stopWatchingProcessOutput({ callbackId });
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
render() {
|
|
68
|
+
return <div className={
|
|
69
|
+
css.fontFamily("monospace").whiteSpace("pre-wrap").fillWidth
|
|
70
|
+
.maxHeight(OUTPUT_MAX_HEIGHT).overflow("auto").pad2(10, 8)
|
|
71
|
+
.hsl(0, 0, 12).colorhsl(0, 0, 90)
|
|
72
|
+
}>
|
|
73
|
+
{parseAnsiColors(this.state.data)}
|
|
74
|
+
</div>;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
class ProcessRow extends qreact.Component<{ record: ProcessRecord; machineNodeId: string }> {
|
|
79
|
+
state = t.state({
|
|
80
|
+
watching: t.type(false),
|
|
81
|
+
});
|
|
82
|
+
render() {
|
|
83
|
+
let record = this.props.record;
|
|
84
|
+
let now = Querysub.nowDelayed(timeInSecond);
|
|
85
|
+
let running = record.deadTime === undefined;
|
|
86
|
+
let color = running && RUNNING_COLOR || DEAD_COLOR;
|
|
87
|
+
return <div className={css.vbox(6).fillWidth.pad2(10, 8).bord2(color.h, color.s, color.l - 20).hsl(color.h, color.s, color.l)}>
|
|
88
|
+
<div className={css.hbox(10).wrap.alignItems("center")}>
|
|
89
|
+
<div className={css.boldStyle}>{record.screenName}</div>
|
|
90
|
+
<div className={css.colorhsl(0, 0, 35)}>{record.serviceKey} #{record.index}</div>
|
|
91
|
+
<div className={css.hbox(4)}>
|
|
92
|
+
<span>started</span>
|
|
93
|
+
<span className={css.boldStyle}>{formatDateTimeDetailed(record.startTime)}</span>
|
|
94
|
+
<span className={css.colorhsl(0, 0, 40)}>({formatTime(now - record.startTime)} ago)</span>
|
|
95
|
+
</div>
|
|
96
|
+
{running && <div>● running for {formatTime(now - record.startTime)}</div>}
|
|
97
|
+
{!running && <div className={css.hbox(4)}>
|
|
98
|
+
<span>died</span>
|
|
99
|
+
<span className={css.boldStyle}>{formatDateTimeDetailed(record.deadTime || 0)}</span>
|
|
100
|
+
<span className={css.colorhsl(0, 0, 40)}>
|
|
101
|
+
({formatTime(now - (record.deadTime || 0))} ago, ran {formatTime((record.deadTime || 0) - record.startTime)})
|
|
102
|
+
</span>
|
|
103
|
+
</div>}
|
|
104
|
+
{record.pid !== undefined && <div className={css.colorhsl(0, 0, 45)}>pid {record.pid}</div>}
|
|
105
|
+
<div className={css.flexGrow(1)} />
|
|
106
|
+
<Button flavor="tiny" onClick={() => this.state.watching = !this.state.watching}>
|
|
107
|
+
{this.state.watching && "Hide output" || "Watch output"}
|
|
108
|
+
</Button>
|
|
109
|
+
</div>
|
|
110
|
+
{this.state.watching && <ProcessOutput record={record} machineNodeId={this.props.machineNodeId} />}
|
|
111
|
+
</div>;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
class MachineProcesses extends qreact.Component<{ machineId: string; applyNodeId: string; serviceId?: string }> {
|
|
116
|
+
state = t.state({
|
|
117
|
+
showHistorical: t.type(false),
|
|
118
|
+
});
|
|
119
|
+
render() {
|
|
120
|
+
let records = MachineController(SocketFunction.browserNodeId()).listOtherProcesses({ nodeId: this.props.applyNodeId });
|
|
121
|
+
if (!records) return <div className={css.pad2(10, 8)}>{this.props.machineId}: loading processes...</div>;
|
|
122
|
+
let shown = records;
|
|
123
|
+
if (this.props.serviceId) {
|
|
124
|
+
shown = shown.filter(x => x.serviceId === this.props.serviceId);
|
|
125
|
+
}
|
|
126
|
+
let running = shown.filter(x => x.deadTime === undefined);
|
|
127
|
+
let historical = shown.filter(x => x.deadTime !== undefined);
|
|
128
|
+
sort(running, x => -x.startTime);
|
|
129
|
+
sort(historical, x => -(x.deadTime || 0));
|
|
130
|
+
return <div className={css.vbox(8).fillWidth}>
|
|
131
|
+
<div className={css.hbox(10).wrap.alignItems("center")}>
|
|
132
|
+
<div className={css.boldStyle}>{this.props.machineId}</div>
|
|
133
|
+
<div className={css.colorhsl(0, 0, 45)}>{running.length} running</div>
|
|
134
|
+
{historical.length > 0 && <Button flavor="tiny" onClick={() => this.state.showHistorical = !this.state.showHistorical}>
|
|
135
|
+
{this.state.showHistorical && `Hide ${historical.length} historical` || `Show ${historical.length} historical`}
|
|
136
|
+
</Button>}
|
|
137
|
+
</div>
|
|
138
|
+
{running.map(record => <ProcessRow key={record.launchId} record={record} machineNodeId={this.props.applyNodeId} />)}
|
|
139
|
+
{this.state.showHistorical && historical.map(record =>
|
|
140
|
+
<ProcessRow key={record.launchId} record={record} machineNodeId={this.props.applyNodeId} />
|
|
141
|
+
)}
|
|
142
|
+
</div>;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Every machine's processes. The listing is cached per machine, so Invalidate empties it and each machine repopulates as its own call returns - watching a process streams live regardless. */
|
|
147
|
+
export class ProcessesView extends qreact.Component<{
|
|
148
|
+
machines: { machineId: string; applyNodeId: string }[];
|
|
149
|
+
serviceId?: string;
|
|
150
|
+
}> {
|
|
151
|
+
render() {
|
|
152
|
+
return <div className={css.vbox(14).fillWidth}>
|
|
153
|
+
<div className={css.hbox(10).alignItems("center")}>
|
|
154
|
+
<h3 className={css.flexGrow(1)}>Processes</h3>
|
|
155
|
+
<Button onClick={() => {
|
|
156
|
+
MachineController(SocketFunction.browserNodeId()).listOtherProcesses.resetAll();
|
|
157
|
+
}}>
|
|
158
|
+
Invalidate
|
|
159
|
+
</Button>
|
|
160
|
+
</div>
|
|
161
|
+
{this.props.machines.map(machine => <MachineProcesses
|
|
162
|
+
key={machine.machineId}
|
|
163
|
+
machineId={machine.machineId}
|
|
164
|
+
applyNodeId={machine.applyNodeId}
|
|
165
|
+
serviceId={this.props.serviceId}
|
|
166
|
+
/>)}
|
|
167
|
+
</div>;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -8,30 +8,24 @@ import { currentViewParam, selectedServiceIdParam, selectedMachineIdParam } from
|
|
|
8
8
|
import { formatDateTime, formatDateTimeDetailed, formatTime, formatVeryNiceDateTime } from "socket-function/src/formatting/format";
|
|
9
9
|
import { InputPicker } from "../../library-components/InputPicker";
|
|
10
10
|
import { MachinePicker } from "./MachinePicker";
|
|
11
|
-
import { deepCloneJSON,
|
|
11
|
+
import { deepCloneJSON, sort, timeInSecond } from "socket-function/src/misc";
|
|
12
12
|
import { InputLabel } from "../../library-components/InputLabel";
|
|
13
13
|
import { Button } from "../../library-components/Button";
|
|
14
14
|
import { isDefined } from "../../misc";
|
|
15
|
-
import {
|
|
15
|
+
import { ProcessesView } from "./ProcessesView";
|
|
16
16
|
import { getPathStr2 } from "../../path";
|
|
17
17
|
import { ATag, Anchor } from "../../library-components/ATag";
|
|
18
18
|
import { ScrollOnMount } from "../../library-components/ScrollOnMount";
|
|
19
|
-
import { StickyBottomScroll } from "../../library-components/StickyBottomScroll";
|
|
20
19
|
import { PrimitiveDisplay } from "../../diagnostics/logs/ObjectDisplay";
|
|
21
|
-
import { parseAnsiColors, rgbToHsl } from "../../diagnostics/logs/ansiFormat";
|
|
22
20
|
import { RenderGitRefInfo, UpdateServiceButtons, bigEmoji, buttonStyle } from "./deployButtons";
|
|
23
21
|
import { TypedConfigEditor } from "../../library-components/TypedConfigEditor";
|
|
24
22
|
import { managementPageURL } from "../../diagnostics/managementPages";
|
|
25
23
|
import { getLogViewerParams } from "../../diagnostics/logs/IndexedLogs/LogViewerParams";
|
|
26
|
-
import { getScreenName } from "../
|
|
24
|
+
import { getScreenName } from "../processManager";
|
|
27
25
|
import { getOwnThreadId } from "../../-f-node-discovery/NodeDiscovery";
|
|
28
26
|
import { decodeNodeId } from "sliftutils/misc/https/certs";
|
|
29
27
|
import { showModal } from "../../5-diagnostics/Modal";
|
|
30
28
|
|
|
31
|
-
// Trimmed well below the limit, so trimming is rare - each trim jumps the relative scroll position
|
|
32
|
-
const OUTPUT_BUFFER_LIMIT = 1_000_000;
|
|
33
|
-
const OUTPUT_BUFFER_KEPT = 100_000;
|
|
34
|
-
|
|
35
29
|
export class ServiceDetailPage extends qreact.Component {
|
|
36
30
|
state = t.state({
|
|
37
31
|
// The editor's current value. Purely in the browser — nothing is written anywhere until a deploy is scheduled (or forced). Whether there are unsaved changes is DERIVED by comparing this to the deployed config, never tracked separately.
|
|
@@ -41,13 +35,6 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
41
35
|
expandedErrors: t.lookup({
|
|
42
36
|
expanded: t.type(false)
|
|
43
37
|
}),
|
|
44
|
-
watchingOutputs: t.lookup({
|
|
45
|
-
isWatching: t.type(false),
|
|
46
|
-
data: t.type(""),
|
|
47
|
-
callbackId: t.type(""),
|
|
48
|
-
// The launch the buffered output belongs to. A new launch is a new process, so its output must not be appended to the previous one's.
|
|
49
|
-
launchTime: t.number(0),
|
|
50
|
-
}),
|
|
51
38
|
// Milliseconds; 0 means no scheduled time picked yet (use Deploy Now instead)
|
|
52
39
|
switchTime: t.number(0),
|
|
53
40
|
// Seconds until the release goes live; 0 means use the default (DEFAULT_OVERLAP_TIME)
|
|
@@ -66,66 +53,6 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
66
53
|
this.state.editorState = updatedConfig;
|
|
67
54
|
}
|
|
68
55
|
|
|
69
|
-
private async startWatchingOutput(config: {
|
|
70
|
-
nodeId: string;
|
|
71
|
-
key: string;
|
|
72
|
-
index: number;
|
|
73
|
-
launchTime: number;
|
|
74
|
-
}) {
|
|
75
|
-
const { nodeId, key, index, launchTime } = config;
|
|
76
|
-
const outputKey = getPathStr2(key, index + "");
|
|
77
|
-
let callbackId = nextId();
|
|
78
|
-
|
|
79
|
-
// Drop the previous watch first: two live callbacks writing to one buffer is what interlaced the output of the old and new processes
|
|
80
|
-
let previousCallbackId = Querysub.localRead(() => this.state.watchingOutputs[outputKey].callbackId);
|
|
81
|
-
if (previousCallbackId) {
|
|
82
|
-
await stopWatchingScreenOutput({ callbackId: previousCallbackId });
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
Querysub.commit(() => {
|
|
86
|
-
// Cleared, so the buffer only ever holds the output of the launch it is watching
|
|
87
|
-
this.state.watchingOutputs[outputKey] = { isWatching: true, data: "", callbackId, launchTime };
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
await watchScreenOutput({
|
|
91
|
-
nodeId,
|
|
92
|
-
key,
|
|
93
|
-
index,
|
|
94
|
-
callbackId,
|
|
95
|
-
onData: async (data: string, dataConfig?: { reset?: boolean }) => {
|
|
96
|
-
Querysub.localCommit(() => {
|
|
97
|
-
let watchingState = this.state.watchingOutputs[outputKey];
|
|
98
|
-
// A callback that outlived its watch (a restart raced with in-flight data) must not write into the new process's buffer
|
|
99
|
-
if (watchingState.callbackId !== callbackId) return;
|
|
100
|
-
// The screen's process changed, so what we have belongs to a process that is gone
|
|
101
|
-
let fullData = (dataConfig?.reset && "" || watchingState.data) + data;
|
|
102
|
-
// Don't trim every time, otherwise the relative scroll position changes by too much
|
|
103
|
-
if (fullData.length > OUTPUT_BUFFER_LIMIT) {
|
|
104
|
-
fullData = fullData.slice(-OUTPUT_BUFFER_KEPT);
|
|
105
|
-
}
|
|
106
|
-
watchingState.data = fullData;
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
private async stopWatchingOutput(key: string, index: number) {
|
|
113
|
-
const outputKey = getPathStr2(key, index + "");
|
|
114
|
-
|
|
115
|
-
let callbackId = Querysub.localRead(() => {
|
|
116
|
-
const watchingState = this.state.watchingOutputs[outputKey];
|
|
117
|
-
let previousCallbackId = watchingState.callbackId;
|
|
118
|
-
watchingState.isWatching = false;
|
|
119
|
-
// Clearing this stops any in-flight data from landing in the buffer, and marks that there is no watch to stop next time
|
|
120
|
-
watchingState.callbackId = "";
|
|
121
|
-
return previousCallbackId;
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
Querysub.onCommitFinished(async () => {
|
|
125
|
-
await stopWatchingScreenOutput({ callbackId });
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
|
|
129
56
|
// Deploys the editor's config (with parameters.releaseTime deciding when it goes live). The editor is NEVER reset — it is set to exactly what was deployed, so it keeps its content and simply compares as having no unsaved changes.
|
|
130
57
|
private deployConfig(deployConfig: ServiceConfig) {
|
|
131
58
|
// Do not let them update the serviceId, as that would break things
|
|
@@ -435,13 +362,8 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
435
362
|
}
|
|
436
363
|
|
|
437
364
|
let key = config.parameters.key;
|
|
438
|
-
const outputKey = getPathStr2(key, index + "");
|
|
439
|
-
const isWatching = this.state.watchingOutputs[outputKey].isWatching;
|
|
440
|
-
let outputData = this.state.watchingOutputs[outputKey].data;
|
|
441
365
|
const screenName = getScreenName({ serviceKey: key, index });
|
|
442
366
|
|
|
443
|
-
let launchTime = serviceInfo?.lastLaunchedTime || 0;
|
|
444
|
-
|
|
445
367
|
return <div key={machineId}
|
|
446
368
|
className={css.pad2(12).vbox(10).bord2(0, 0, 20).fillWidth + backgroundColor}
|
|
447
369
|
>
|
|
@@ -510,24 +432,6 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
510
432
|
</Anchor>
|
|
511
433
|
|
|
512
434
|
|
|
513
|
-
<div
|
|
514
|
-
className={css.button.pad2(16, 8).bord2(0, 0, 10) + (isWatching ? css.hsl(0, 70, 90) : css.hsl(120, 70, 90))
|
|
515
|
-
}
|
|
516
|
-
onClick={(e) => {
|
|
517
|
-
e.stopPropagation();
|
|
518
|
-
let applyNodeId = machineInfo.applyNodeId;
|
|
519
|
-
Querysub.onCommitFinished(() => {
|
|
520
|
-
if (isWatching) {
|
|
521
|
-
void this.stopWatchingOutput(key, index);
|
|
522
|
-
} else {
|
|
523
|
-
void this.startWatchingOutput({ nodeId: applyNodeId, key, index, launchTime });
|
|
524
|
-
}
|
|
525
|
-
});
|
|
526
|
-
}}
|
|
527
|
-
>
|
|
528
|
-
{isWatching ? "Stop Watching Output" : "Watch Screen Output"}
|
|
529
|
-
</div>
|
|
530
|
-
|
|
531
435
|
<ATag values={getLogViewerParams({ __machineId: machineId })}>
|
|
532
436
|
Machine Logs
|
|
533
437
|
</ATag>
|
|
@@ -559,34 +463,15 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
559
463
|
</div>
|
|
560
464
|
)}
|
|
561
465
|
|
|
562
|
-
{isWatching &&
|
|
563
|
-
<div
|
|
564
|
-
className={
|
|
565
|
-
css.pad2(8).bord2(0, 0, 10).hsl(0, 0, 10).colorhsl(0, 0, 100)
|
|
566
|
-
.whiteSpace("pre-wrap").fontFamily("monospace")
|
|
567
|
-
.overflowAuto
|
|
568
|
-
.height("60vh")
|
|
569
|
-
.vbox0
|
|
570
|
-
.fillWidth
|
|
571
|
-
}
|
|
572
|
-
onClick={e => e.stopPropagation()}
|
|
573
|
-
>
|
|
574
|
-
<div className={css.flexShrink0}>
|
|
575
|
-
{(() => {
|
|
576
|
-
let parts = parseAnsiColors(outputData);
|
|
577
|
-
return parts.map(({ text, color }) => {
|
|
578
|
-
if (!color) return <span>{text}</span>;
|
|
579
|
-
let hue = rgbToHsl(color).h;
|
|
580
|
-
return <span className={css.hsl(hue, 60, 30)}>{text}</span>;
|
|
581
|
-
});
|
|
582
|
-
})()}
|
|
583
|
-
</div>
|
|
584
|
-
<StickyBottomScroll debugText={`Screen ${outputKey}`} time={Date.now()} />
|
|
585
|
-
</div>
|
|
586
|
-
}
|
|
587
466
|
</div>;
|
|
588
467
|
})}
|
|
589
468
|
</div>
|
|
469
|
+
<ProcessesView
|
|
470
|
+
serviceId={selectedServiceId || ""}
|
|
471
|
+
machines={machineStatuses
|
|
472
|
+
.filter(x => x.machineInfo)
|
|
473
|
+
.map(x => ({ machineId: x.machineId, applyNodeId: x.machineInfo!.applyNodeId }))}
|
|
474
|
+
/>
|
|
590
475
|
</div>}
|
|
591
476
|
|
|
592
477
|
<div className={css.hbox(12).fillWidth}>
|
|
Binary file
|
|
@@ -5,7 +5,8 @@ import { requiresNetworkTrustHook } from "../-d-trust/NetworkTrust2";
|
|
|
5
5
|
import { timeInMinute } from "socket-function/src/misc";
|
|
6
6
|
import { assertIsManagementUser } from "../diagnostics/managementPages";
|
|
7
7
|
import { isNode } from "typesafecss";
|
|
8
|
-
import {
|
|
8
|
+
import { streamProcessOutput } from "./processManager";
|
|
9
|
+
import { ProcessRecord, listProcessRecords } from "./processLogs";
|
|
9
10
|
import { Querysub } from "../4-querysub/Querysub";
|
|
10
11
|
import { getPathStr2 } from "../path";
|
|
11
12
|
import { getGitURLLive, setGitRef } from "../4-deploy/git";
|
|
@@ -72,38 +73,43 @@ export const OnServiceChange = SocketFunction.register(
|
|
|
72
73
|
|
|
73
74
|
class MachineControllerBase {
|
|
74
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...
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
/** Every process this machine has a record of, running or dead. */
|
|
77
|
+
public async listProcesses(): Promise<ProcessRecord[]> {
|
|
78
|
+
return await listProcessRecords();
|
|
79
|
+
}
|
|
80
|
+
public async streamProcessOutput(config: {
|
|
81
|
+
folder: string;
|
|
82
|
+
launchId: string;
|
|
78
83
|
callbackId: string;
|
|
79
84
|
}): Promise<void> {
|
|
80
85
|
let caller = SocketFunction.getCaller();
|
|
81
|
-
await
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
onData: async (data
|
|
86
|
+
await streamProcessOutput({
|
|
87
|
+
folder: config.folder,
|
|
88
|
+
launchId: config.launchId,
|
|
89
|
+
onData: async (data) => {
|
|
85
90
|
await MachineControllerClient.nodes[caller.nodeId].onScreenOutput({
|
|
86
|
-
|
|
87
|
-
index: config.index,
|
|
91
|
+
launchId: config.launchId,
|
|
88
92
|
data,
|
|
89
|
-
reset: dataConfig?.reset,
|
|
90
93
|
callbackId: config.callbackId,
|
|
91
94
|
});
|
|
92
95
|
},
|
|
93
96
|
});
|
|
94
97
|
}
|
|
95
|
-
// We need to forward
|
|
96
|
-
public async
|
|
98
|
+
// We need to forward these, as clients can't connect to the machines directly (they don't have real HTTP certificates).
|
|
99
|
+
public async listOtherProcesses(config: { nodeId: string }): Promise<ProcessRecord[]> {
|
|
100
|
+
return await MachineController(config.nodeId).listProcesses.promise();
|
|
101
|
+
}
|
|
102
|
+
public async watchOtherProcessOutput(config: {
|
|
97
103
|
nodeId: string;
|
|
98
|
-
|
|
99
|
-
|
|
104
|
+
folder: string;
|
|
105
|
+
launchId: string;
|
|
100
106
|
callbackId: string;
|
|
101
107
|
}) {
|
|
102
108
|
let caller = SocketFunction.getCaller();
|
|
103
109
|
forwardedCallbacks.set(config.callbackId, caller.nodeId);
|
|
104
|
-
await MachineController(config.nodeId).
|
|
105
|
-
|
|
106
|
-
|
|
110
|
+
await MachineController(config.nodeId).streamProcessOutput.promise({
|
|
111
|
+
folder: config.folder,
|
|
112
|
+
launchId: config.launchId,
|
|
107
113
|
callbackId: config.callbackId,
|
|
108
114
|
});
|
|
109
115
|
}
|
|
@@ -137,8 +143,10 @@ export const MachineController = getSyncedController(SocketFunction.register(
|
|
|
137
143
|
"machine-controller-c3157d4a-580c-4e76-9dc9-072dd92e70af",
|
|
138
144
|
() => new MachineControllerBase(),
|
|
139
145
|
() => ({
|
|
140
|
-
|
|
141
|
-
|
|
146
|
+
listProcesses: {},
|
|
147
|
+
streamProcessOutput: {},
|
|
148
|
+
listOtherProcesses: {},
|
|
149
|
+
watchOtherProcessOutput: {},
|
|
142
150
|
deployMachineFromBrowser: {},
|
|
143
151
|
deployMachine: {},
|
|
144
152
|
}),
|
|
@@ -153,26 +161,25 @@ export const MachineController = getSyncedController(SocketFunction.register(
|
|
|
153
161
|
reads: {},
|
|
154
162
|
});
|
|
155
163
|
|
|
156
|
-
let callbacks = new Map<string, (data: string
|
|
157
|
-
export async function
|
|
164
|
+
let callbacks = new Map<string, (data: string) => Promise<void>>();
|
|
165
|
+
export async function watchProcessOutput(config: {
|
|
158
166
|
nodeId: string;
|
|
159
|
-
|
|
160
|
-
|
|
167
|
+
folder: string;
|
|
168
|
+
launchId: string;
|
|
161
169
|
callbackId: string;
|
|
162
|
-
|
|
163
|
-
onData: (data: string, config?: { reset?: boolean }) => Promise<void>;
|
|
170
|
+
onData: (data: string) => Promise<void>;
|
|
164
171
|
}) {
|
|
165
172
|
let callbackId = config.callbackId;
|
|
166
173
|
callbacks.set(callbackId, config.onData);
|
|
167
|
-
await MachineController(SocketFunction.browserNodeId()).
|
|
174
|
+
await MachineController(SocketFunction.browserNodeId()).watchOtherProcessOutput.promise({
|
|
168
175
|
nodeId: config.nodeId,
|
|
169
|
-
|
|
170
|
-
|
|
176
|
+
folder: config.folder,
|
|
177
|
+
launchId: config.launchId,
|
|
171
178
|
callbackId,
|
|
172
179
|
});
|
|
173
180
|
}
|
|
174
181
|
|
|
175
|
-
export async function
|
|
182
|
+
export async function stopWatchingProcessOutput(config: {
|
|
176
183
|
callbackId: string;
|
|
177
184
|
}) {
|
|
178
185
|
let callbackId = config.callbackId;
|
|
@@ -180,19 +187,15 @@ export async function stopWatchingScreenOutput(config: {
|
|
|
180
187
|
}
|
|
181
188
|
class MachineControllerClientBase {
|
|
182
189
|
public async onScreenOutput(config: {
|
|
183
|
-
|
|
184
|
-
index: number;
|
|
190
|
+
launchId: string;
|
|
185
191
|
data: string;
|
|
186
|
-
reset?: boolean;
|
|
187
192
|
callbackId: string;
|
|
188
193
|
}): Promise<void> {
|
|
189
194
|
let forwardToNodeId = forwardedCallbacks.get(config.callbackId);
|
|
190
195
|
if (forwardToNodeId) {
|
|
191
196
|
await MachineControllerClient.nodes[forwardToNodeId].onScreenOutput({
|
|
192
|
-
|
|
193
|
-
index: config.index,
|
|
197
|
+
launchId: config.launchId,
|
|
194
198
|
data: config.data,
|
|
195
|
-
reset: config.reset,
|
|
196
199
|
callbackId: config.callbackId,
|
|
197
200
|
});
|
|
198
201
|
return;
|
|
@@ -202,7 +205,7 @@ class MachineControllerClientBase {
|
|
|
202
205
|
if (!callback) {
|
|
203
206
|
throw new Error(`Callback ${config.callbackId} not found (likely removed)`);
|
|
204
207
|
}
|
|
205
|
-
await callback(config.data
|
|
208
|
+
await callback(config.data);
|
|
206
209
|
}
|
|
207
210
|
}
|
|
208
211
|
// NOTE: THis is secure, because callbackId is random.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { SERVICE_FOLDER, ServiceParameters } from "./machineSchema";
|
|
5
|
+
|
|
6
|
+
// One log file per process, appended to until it passes LOG_SIZE_LIMIT, then emptied wholesale. Watchers see the file shrink and re-seek to the start. A watcher attaching in the moment right after a reset sees an empty log, which at this size is rare enough to be worth how much simpler this is than rolling files.
|
|
7
|
+
const LOG_SIZE_LIMIT = 10 * 1024 * 1024;
|
|
8
|
+
// Checked every this many lines, so the size check costs nothing per line
|
|
9
|
+
const LOG_SIZE_CHECK_LINES = 1000;
|
|
10
|
+
/** How long a dead process's log is kept before it is deleted. */
|
|
11
|
+
export const DEAD_PROCESS_RETENTION = 3 * 24 * 60 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
const PROCESS_FOLDER = "processes";
|
|
14
|
+
const RECORD_SUFFIX = ".json";
|
|
15
|
+
const LOG_SUFFIX = ".log";
|
|
16
|
+
const PIPE_SCRIPT_SUFFIX = ".pipe.sh";
|
|
17
|
+
const TAIL_SCRIPT_SUFFIX = ".tail.sh";
|
|
18
|
+
|
|
19
|
+
/** One launch of one service instance: the process, the configuration it was launched with, and its output. Written when the process is launched, and updated only to record its pid and then its death. */
|
|
20
|
+
export type ProcessRecord = {
|
|
21
|
+
launchId: string;
|
|
22
|
+
/** The instance folder this process runs out of. A release's future process shares its canonical instance's folder, so this is what owns the logs, not the tmux session name. */
|
|
23
|
+
folder: string;
|
|
24
|
+
/** The tmux session, which a takeover renames from the future name to the canonical one */
|
|
25
|
+
screenName: string;
|
|
26
|
+
serviceId: string;
|
|
27
|
+
serviceKey: string;
|
|
28
|
+
/** Which instance of the service this is, on this machine */
|
|
29
|
+
index: number;
|
|
30
|
+
machineId: string;
|
|
31
|
+
/** The start time the OS reports for the pid, which is also half of the launchId */
|
|
32
|
+
startTime: number;
|
|
33
|
+
/** When we first noticed the process was gone. Absent while it is running. */
|
|
34
|
+
deadTime?: number;
|
|
35
|
+
pid?: number;
|
|
36
|
+
/** The parameters it was launched with, with template variables already resolved */
|
|
37
|
+
parameters: ServiceParameters;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function getProcessLogPath(folder: string, launchId: string): string {
|
|
41
|
+
return path.join(folder, PROCESS_FOLDER, launchId + LOG_SUFFIX);
|
|
42
|
+
}
|
|
43
|
+
function getRecordPath(folder: string, launchId: string): string {
|
|
44
|
+
return path.join(folder, PROCESS_FOLDER, launchId + RECORD_SUFFIX);
|
|
45
|
+
}
|
|
46
|
+
// The scripts live beside the log so a process's entire footprint is one set of files, deleted together
|
|
47
|
+
export function getPipeScriptPath(folder: string, launchId: string): string {
|
|
48
|
+
return path.join(folder, PROCESS_FOLDER, launchId + PIPE_SCRIPT_SUFFIX);
|
|
49
|
+
}
|
|
50
|
+
export function getTailScriptPath(folder: string, launchId: string): string {
|
|
51
|
+
return path.join(folder, PROCESS_FOLDER, launchId + TAIL_SCRIPT_SUFFIX);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A process identifies itself: its pid, plus the start time the OS reports for it. The pid alone is reused, but a pid running since a specific second is one process, and both halves can be re-checked against the machine at any time instead of trusted from our own bookkeeping. */
|
|
55
|
+
export function createLaunchId(pid: string, startTime: number): string {
|
|
56
|
+
return `${pid}-${startTime}`;
|
|
57
|
+
}
|
|
58
|
+
export function parseLaunchId(launchId: string): { pid: string; startTime: number } {
|
|
59
|
+
let separator = launchId.lastIndexOf("-");
|
|
60
|
+
return {
|
|
61
|
+
pid: launchId.slice(0, separator),
|
|
62
|
+
startTime: parseInt(launchId.slice(separator + 1)) || 0,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The shell a screen's output is piped through: appends every line, and empties the file once it grows past the limit. */
|
|
67
|
+
export function getLogPipeScript(logPath: string): string {
|
|
68
|
+
return `#!/bin/bash
|
|
69
|
+
line_count=0
|
|
70
|
+
while IFS= read -r line; do
|
|
71
|
+
echo "$line" >> "${logPath}"
|
|
72
|
+
((line_count++))
|
|
73
|
+
if (( line_count % ${LOG_SIZE_CHECK_LINES} == 0 )); then
|
|
74
|
+
size=$(stat -c%s "${logPath}" 2>/dev/null || stat -f%z "${logPath}" 2>/dev/null || echo 0)
|
|
75
|
+
if [ "$size" -gt ${LOG_SIZE_LIMIT} ]; then
|
|
76
|
+
: > "${logPath}"
|
|
77
|
+
fi
|
|
78
|
+
fi
|
|
79
|
+
done`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Streams everything appended to a log after the given offset. A file smaller than our offset was emptied by the size limit, so we start over from the front of it. */
|
|
83
|
+
export function getLogTailScript(logPath: string): string {
|
|
84
|
+
return `#!/bin/bash
|
|
85
|
+
CURRENT_POS="$1"
|
|
86
|
+
|
|
87
|
+
while true; do
|
|
88
|
+
sleep 0.25
|
|
89
|
+
if [ -f "${logPath}" ]; then
|
|
90
|
+
SIZE=$(stat -c%s "${logPath}" 2>/dev/null || stat -f%z "${logPath}" 2>/dev/null || echo 0)
|
|
91
|
+
if [ "$SIZE" -lt "$CURRENT_POS" ]; then
|
|
92
|
+
CURRENT_POS=0
|
|
93
|
+
fi
|
|
94
|
+
if [ "$SIZE" -gt "$CURRENT_POS" ]; then
|
|
95
|
+
tail -c +$((CURRENT_POS + 1)) "${logPath}" | head -c $((SIZE - CURRENT_POS))
|
|
96
|
+
CURRENT_POS=$SIZE
|
|
97
|
+
fi
|
|
98
|
+
fi
|
|
99
|
+
done`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function writeProcessRecord(record: ProcessRecord): Promise<void> {
|
|
103
|
+
await fs.promises.mkdir(path.join(record.folder, PROCESS_FOLDER), { recursive: true });
|
|
104
|
+
await fs.promises.writeFile(getRecordPath(record.folder, record.launchId), JSON.stringify(record));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function readFolderRecords(folder: string): Promise<ProcessRecord[]> {
|
|
108
|
+
let processFolder = path.join(folder, PROCESS_FOLDER);
|
|
109
|
+
let files: string[];
|
|
110
|
+
try {
|
|
111
|
+
files = await fs.promises.readdir(processFolder);
|
|
112
|
+
} catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
let records: ProcessRecord[] = [];
|
|
116
|
+
for (let file of files) {
|
|
117
|
+
if (!file.endsWith(RECORD_SUFFIX)) continue;
|
|
118
|
+
records.push(JSON.parse(await fs.promises.readFile(path.join(processFolder, file), "utf8")) as ProcessRecord);
|
|
119
|
+
}
|
|
120
|
+
return records;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Every process this machine has a record of, running or dead, across every service. */
|
|
124
|
+
export async function listProcessRecords(): Promise<ProcessRecord[]> {
|
|
125
|
+
let root = os.homedir() + "/" + SERVICE_FOLDER;
|
|
126
|
+
let instanceFolders: string[];
|
|
127
|
+
try {
|
|
128
|
+
instanceFolders = await fs.promises.readdir(root);
|
|
129
|
+
} catch {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
let records: ProcessRecord[] = [];
|
|
133
|
+
for (let instanceFolder of instanceFolders) {
|
|
134
|
+
records.push(...await readFolderRecords(path.join(root, instanceFolder)));
|
|
135
|
+
}
|
|
136
|
+
return records;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function removeProcessRecord(record: ProcessRecord): Promise<void> {
|
|
140
|
+
let files = [
|
|
141
|
+
getRecordPath(record.folder, record.launchId),
|
|
142
|
+
getProcessLogPath(record.folder, record.launchId),
|
|
143
|
+
getPipeScriptPath(record.folder, record.launchId),
|
|
144
|
+
getTailScriptPath(record.folder, record.launchId),
|
|
145
|
+
];
|
|
146
|
+
for (let file of files) {
|
|
147
|
+
try {
|
|
148
|
+
await fs.promises.unlink(file);
|
|
149
|
+
} catch {
|
|
150
|
+
// Already gone, which is the state we wanted
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Marks every record whose process is gone as dead, and deletes the ones that have been dead past the retention. */
|
|
156
|
+
export async function syncProcessRecords(config: {
|
|
157
|
+
isAlive: (record: ProcessRecord) => Promise<boolean>;
|
|
158
|
+
now: number;
|
|
159
|
+
}): Promise<void> {
|
|
160
|
+
for (let record of await listProcessRecords()) {
|
|
161
|
+
if (record.deadTime === undefined && !await config.isAlive(record)) {
|
|
162
|
+
record.deadTime = config.now;
|
|
163
|
+
console.log(`Process ${record.launchId} (${record.screenName}) is no longer running, marking it dead. Its log is deleted at ${new Date(record.deadTime + DEAD_PROCESS_RETENTION).toISOString()}`);
|
|
164
|
+
await writeProcessRecord(record);
|
|
165
|
+
}
|
|
166
|
+
if (record.deadTime !== undefined && config.now - record.deadTime > DEAD_PROCESS_RETENTION) {
|
|
167
|
+
console.log(`Deleting the log of process ${record.launchId} (${record.screenName}), dead since ${new Date(record.deadTime).toISOString()}`);
|
|
168
|
+
await removeProcessRecord(record);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { spawn, ChildProcess } from "child_process";
|
|
5
|
+
import { lazy } from "socket-function/src/caching";
|
|
6
|
+
import { measureWrap } from "socket-function/src/profiling/measure";
|
|
7
|
+
import { delay, runInSerial } from "socket-function/src/batching";
|
|
8
|
+
import { red, green } from "socket-function/src/formatting/logColors";
|
|
9
|
+
import { runPromise } from "../functional/runCommand";
|
|
10
|
+
import { forceRemoveNode } from "../-f-node-discovery/NodeDiscovery";
|
|
11
|
+
import { fsExistsAsync } from "../fs";
|
|
12
|
+
import { PromiseObj } from "../promise";
|
|
13
|
+
import { SERVICE_FOLDER, SERVICE_NODE_FILE_NAME } from "./machineSchema";
|
|
14
|
+
import { ProcessRecord, createLaunchId, getLogPipeScript, getLogTailScript, getProcessLogPath, getPipeScriptPath, getTailScriptPath, writeProcessRecord } from "./processLogs";
|
|
15
|
+
|
|
16
|
+
// 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
|
+
|
|
18
|
+
const SCREEN_SUFFIX = "-dply";
|
|
19
|
+
export function getScreenName(config: { serviceKey: string; index: number }): string {
|
|
20
|
+
return `${config.serviceKey}-${config.index}${SCREEN_SUFFIX}`.replace(/[^a-zA-Z0-9\-_]/g, "_");
|
|
21
|
+
}
|
|
22
|
+
// The new version's screen during a release, in the SAME folder as the canonical screen: created (just echoing when it will start) shortly before releaseTime, started at releaseTime, and renamed to the canonical screen name once the old screen is killed at releaseTime + overlapTime.
|
|
23
|
+
export function getFutureScreenName(canonicalScreenName: string): string {
|
|
24
|
+
return canonicalScreenName.slice(0, -SCREEN_SUFFIX.length) + "-future" + SCREEN_SUFFIX;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async function removeOldNodeId(screenName: string) {
|
|
29
|
+
let nodeIdFile = os.homedir() + "/" + SERVICE_FOLDER + screenName + "/" + SERVICE_NODE_FILE_NAME;
|
|
30
|
+
if (await fsExistsAsync(nodeIdFile)) {
|
|
31
|
+
let nodeId = await fs.promises.readFile(nodeIdFile, "utf8");
|
|
32
|
+
console.log(green(`Removing node if for dead service on ${nodeIdFile}, node id ${nodeId}`));
|
|
33
|
+
await fs.promises.unlink(nodeIdFile);
|
|
34
|
+
await forceRemoveNode(nodeId);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const getTmuxPrefix = lazy(() => {
|
|
39
|
+
if (os.platform() === "win32") {
|
|
40
|
+
return "C:/cygwin64/bin/";
|
|
41
|
+
// C:/cygwin64/bin/tmux new -s server1 -d
|
|
42
|
+
}
|
|
43
|
+
return "";
|
|
44
|
+
});
|
|
45
|
+
function textTableLineToObj(text: string): Record<string, string>[] {
|
|
46
|
+
/*
|
|
47
|
+
PID PPID PGID WINPID TTY UID STIME COMMAND
|
|
48
|
+
1312 1284 1312 56996 pty2 197609 09:24:47 /usr/bin/bash
|
|
49
|
+
1313 1284 1313 56997 pty3 197609 09:24:48 /usr/bin/vim
|
|
50
|
+
=>
|
|
51
|
+
[{ PID: "1312", PPID: "1284", ...}, { PID: "1313", PPID: "1284", ...}]
|
|
52
|
+
*/
|
|
53
|
+
let lines = text.split("\n").filter(line => line.trim().length > 0);
|
|
54
|
+
if (lines.length < 2) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let headerLine = lines[0];
|
|
59
|
+
let dataLines = lines.slice(1);
|
|
60
|
+
|
|
61
|
+
// Parse column positions from header
|
|
62
|
+
let headerWords = headerLine.match(/\S+/g) || [];
|
|
63
|
+
if (headerWords.length === 0) return [];
|
|
64
|
+
|
|
65
|
+
// Find column boundaries: start at 0, then at end of each header word (except last), then start of last header, then end of line
|
|
66
|
+
let boundaries: number[] = [0];
|
|
67
|
+
let searchPos = 0;
|
|
68
|
+
for (let i = 0; i < headerWords.length; i++) {
|
|
69
|
+
let word = headerWords[i];
|
|
70
|
+
searchPos = headerLine.indexOf(word, searchPos) + word.length;
|
|
71
|
+
boundaries.push(searchPos);
|
|
72
|
+
}
|
|
73
|
+
boundaries[boundaries.length - 1] = Number.MAX_SAFE_INTEGER;
|
|
74
|
+
|
|
75
|
+
// Create column definitions using boundaries
|
|
76
|
+
let columns: { name: string; start: number; end: number }[] = [];
|
|
77
|
+
for (let i = 0; i < headerWords.length; i++) {
|
|
78
|
+
columns.push({
|
|
79
|
+
name: headerWords[i],
|
|
80
|
+
start: boundaries[i],
|
|
81
|
+
end: boundaries[i + 1]
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Extract values from all data lines
|
|
86
|
+
let results: Record<string, string>[] = [];
|
|
87
|
+
for (let dataLine of dataLines) {
|
|
88
|
+
let result: Record<string, string> = {};
|
|
89
|
+
for (let column of columns) {
|
|
90
|
+
let value = dataLine.substring(column.start, column.end).trim();
|
|
91
|
+
result[column.name] = value;
|
|
92
|
+
}
|
|
93
|
+
results.push(result);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return results;
|
|
97
|
+
}
|
|
98
|
+
const getLinuxChildPids = measureWrap(async function getLinuxChildPids(pid: string): Promise<{ PID: string; PPID: string; CMD: string }[]> {
|
|
99
|
+
let prefix = getTmuxPrefix();
|
|
100
|
+
if (os.platform() === "win32") {
|
|
101
|
+
let table = await runPromise(`${prefix}ps`, { quiet: true });
|
|
102
|
+
let obj = textTableLineToObj(table) as { PID: string; PPID: string; CMD: string }[];
|
|
103
|
+
return obj.filter(x => x.PPID === pid);
|
|
104
|
+
} else {
|
|
105
|
+
let table = await runPromise(`ps -eo pid,ppid,cmd`, { quiet: true });
|
|
106
|
+
let obj = textTableLineToObj(table) as { PPID: string; PID: string; CMD: string }[];
|
|
107
|
+
return obj.filter(x => x.PPID === pid);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
// One process-table read for the whole machine — a pid is "running something" when any process has it as a parent. Used by getScreenState so a resync doesn't spawn one ps per screen (which made the release boundary ticks wait on every idle screen first).
|
|
111
|
+
const getAllParentPids = measureWrap(async function getAllParentPids(): Promise<Set<string>> {
|
|
112
|
+
let prefix = getTmuxPrefix();
|
|
113
|
+
let table = os.platform() === "win32"
|
|
114
|
+
? await runPromise(`${prefix}ps`, { quiet: true })
|
|
115
|
+
: await runPromise(`ps -eo pid,ppid,cmd`, { quiet: true });
|
|
116
|
+
let obj = textTableLineToObj(table) as { PID: string; PPID: string }[];
|
|
117
|
+
return new Set(obj.map(x => x.PPID));
|
|
118
|
+
});
|
|
119
|
+
/** The start time the OS reports for a pid, as an epoch time in milliseconds like every other time we store. Asked of the machine rather than remembered, so it stays true across our restarts and catches a reused pid. Returns 0 when the process is gone. */
|
|
120
|
+
export const getProcessStartTime = measureWrap(async function getProcessStartTime(pid: string): Promise<number> {
|
|
121
|
+
// ps only resolves to the second, so this is second-accurate - which is plenty to tell two processes on the same pid apart
|
|
122
|
+
let started = await runPromise(`date -d "$(ps -o lstart= -p ${pid})" +%s`, { quiet: true });
|
|
123
|
+
return (parseInt(started.trim()) || 0) * 1000;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export const isScreenRunningProcess = measureWrap(async function isScreenRunningProcess(pid: string): Promise<boolean> {
|
|
127
|
+
try {
|
|
128
|
+
let bashChildPids = await getLinuxChildPids(pid);
|
|
129
|
+
for (let childPid of bashChildPids) {
|
|
130
|
+
console.log(`Screen pid ${pid} is running ${childPid.CMD}`);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
console.log(`Screen pid ${pid} is not running anything.`);
|
|
134
|
+
return false;
|
|
135
|
+
} catch (e: any) {
|
|
136
|
+
console.warn(`Error checking if screen is running for ${pid}: ${e.stack}`);
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
export const getScreenState = measureWrap(async function getScreenState(populateIsProcessRunning: boolean = true): Promise<{
|
|
141
|
+
screenName: string;
|
|
142
|
+
isProcessRunning: boolean;
|
|
143
|
+
pid: string;
|
|
144
|
+
}[]> {
|
|
145
|
+
const prefix = getTmuxPrefix();
|
|
146
|
+
const delimit = "::::";
|
|
147
|
+
// Use list-sessions instead of list-panes -a to avoid zombie sessions
|
|
148
|
+
// 2>/dev/null suppresses "no server running" errors, -r prevents xargs from running if input is empty
|
|
149
|
+
let screenList = (await runPromise(`${prefix}tmux list-sessions -F "#{session_name}" 2>/dev/null | xargs -r -I {} tmux list-panes -t {} -F "{}${delimit}#{pane_pid}"`))
|
|
150
|
+
.split("\n")
|
|
151
|
+
.filter(x => x.includes(delimit))
|
|
152
|
+
.map(x => ({
|
|
153
|
+
screenName: x.split(delimit)[0].trim(),
|
|
154
|
+
pid: x.split(delimit)[1].trim(),
|
|
155
|
+
isProcessRunning: false,
|
|
156
|
+
}))
|
|
157
|
+
.filter(x => x.screenName.endsWith(SCREEN_SUFFIX))
|
|
158
|
+
;
|
|
159
|
+
|
|
160
|
+
if (populateIsProcessRunning) {
|
|
161
|
+
let parentPids = await getAllParentPids();
|
|
162
|
+
for (let x of screenList) {
|
|
163
|
+
x.isProcessRunning = parentPids.has(x.pid);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return screenList;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
export const runScreenCommand = measureWrap(async function runScreenCommand(config: {
|
|
171
|
+
screenName: string;
|
|
172
|
+
command: string;
|
|
173
|
+
// Defaults to the folder derived from screenName; a future screen passes its canonical screen's folder
|
|
174
|
+
folder?: string;
|
|
175
|
+
// Identifies the process this launch creates, so its log and its configuration are stored against it
|
|
176
|
+
record: Omit<ProcessRecord, "launchId" | "folder" | "screenName" | "startTime">;
|
|
177
|
+
}): Promise<string> {
|
|
178
|
+
let prefix = getTmuxPrefix();
|
|
179
|
+
let screenName = config.screenName;
|
|
180
|
+
|
|
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
|
+
}
|
|
217
|
+
}
|
|
218
|
+
await removeOldNodeId(screenName);
|
|
219
|
+
let folder = config.folder || os.homedir() + "/" + SERVICE_FOLDER + screenName + "/";
|
|
220
|
+
|
|
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.
|
|
222
|
+
let panePid = (await getScreenState(false)).find(x => x.screenName === screenName)?.pid;
|
|
223
|
+
if (!panePid) {
|
|
224
|
+
throw new Error(`Screen ${screenName} does not exist after creating it, so there is no process to run the command in`);
|
|
225
|
+
}
|
|
226
|
+
let startTime = await getProcessStartTime(panePid);
|
|
227
|
+
let launchId = createLaunchId(panePid, startTime);
|
|
228
|
+
await writeProcessRecord({
|
|
229
|
+
...config.record,
|
|
230
|
+
launchId,
|
|
231
|
+
folder,
|
|
232
|
+
screenName,
|
|
233
|
+
pid: parseInt(panePid) || undefined,
|
|
234
|
+
startTime,
|
|
235
|
+
});
|
|
236
|
+
await runPromise(`${prefix}tmux send-keys -t ${screenName} 'cd ${folder}git' Enter`);
|
|
237
|
+
let command = `#!/bin/bash
|
|
238
|
+
${config.command}
|
|
239
|
+
`;
|
|
240
|
+
await fs.promises.writeFile(folder + "command.sh", command);
|
|
241
|
+
await runPromise(`${prefix}tmux send-keys -t ${screenName} 'bash ../command.sh' Enter`);
|
|
242
|
+
|
|
243
|
+
await setupPipePane({ screenName, folder, launchId });
|
|
244
|
+
return launchId;
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Points the screen's pipe-pane at this launch's own log file.
|
|
248
|
+
async function setupPipePane(config: { screenName: string; folder: string; launchId: string }) {
|
|
249
|
+
let prefix = getTmuxPrefix();
|
|
250
|
+
let logPath = getProcessLogPath(config.folder, config.launchId);
|
|
251
|
+
let pipeScript = getPipeScriptPath(config.folder, config.launchId);
|
|
252
|
+
await fs.promises.mkdir(path.dirname(logPath), { recursive: true });
|
|
253
|
+
await fs.promises.writeFile(pipeScript, getLogPipeScript(logPath));
|
|
254
|
+
await runPromise(`chmod +x ${pipeScript}`);
|
|
255
|
+
await runPromise(`${prefix}tmux pipe-pane -t ${config.screenName} 'bash ${pipeScript}'`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export const killScreen = measureWrap(async function killScreen(config: {
|
|
259
|
+
screenName: string;
|
|
260
|
+
// During a takeover the folder's nodeId file already belongs to the NEW process, so the old screen's kill must not remove it
|
|
261
|
+
skipNodeIdRemoval?: boolean;
|
|
262
|
+
}) {
|
|
263
|
+
console.log(red(`Killing screen ${config.screenName}`));
|
|
264
|
+
let prefix = getTmuxPrefix();
|
|
265
|
+
// Try ctrl+c a few times first
|
|
266
|
+
let pid = (await getScreenState(false)).find(x => x.screenName === config.screenName)?.pid;
|
|
267
|
+
for (let i = 0; i < 5; i++) {
|
|
268
|
+
if (!pid || !await isScreenRunningProcess(pid)) {
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
await runPromise(`${prefix}tmux send-keys -t ${config.screenName} 'C-c' Enter`);
|
|
272
|
+
await delay(5000);
|
|
273
|
+
}
|
|
274
|
+
await runPromise(`${prefix}tmux kill-session -t ${config.screenName}`);
|
|
275
|
+
if (!config.skipNodeIdRemoval) {
|
|
276
|
+
await removeOldNodeId(config.screenName);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
/** Streams one process's log: everything already in it, then everything appended after. One process, one file - nothing here has to reason about which process a byte came from. */
|
|
281
|
+
export async function streamProcessOutput(config: {
|
|
282
|
+
folder: string;
|
|
283
|
+
launchId: string;
|
|
284
|
+
onData: (data: string) => Promise<void>;
|
|
285
|
+
}) {
|
|
286
|
+
let logPath = getProcessLogPath(config.folder, config.launchId);
|
|
287
|
+
let serialOnData = runInSerial(config.onData);
|
|
288
|
+
let stopped = false;
|
|
289
|
+
let childProcess: ChildProcess | undefined;
|
|
290
|
+
|
|
291
|
+
let pendingDataCalls = 0;
|
|
292
|
+
const MAX_PENDING_CALLS = 100;
|
|
293
|
+
|
|
294
|
+
async function stop() {
|
|
295
|
+
if (stopped) return;
|
|
296
|
+
stopped = true;
|
|
297
|
+
if (childProcess) {
|
|
298
|
+
childProcess.kill();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const onDataWrapped = async (data: string) => {
|
|
303
|
+
pendingDataCalls++;
|
|
304
|
+
if (pendingDataCalls > MAX_PENDING_CALLS) {
|
|
305
|
+
console.error(`Too many queued onData calls for ${config.launchId}, stopping stream.`);
|
|
306
|
+
await stop();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
await serialOnData(data);
|
|
311
|
+
} catch (e: any) {
|
|
312
|
+
console.log(`Callback for stream output ${config.launchId} failed. It probably just disconnected, almost certainly not an error: ${e.message}`);
|
|
313
|
+
await stop();
|
|
314
|
+
} finally {
|
|
315
|
+
pendingDataCalls--;
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
let tailScript = getTailScriptPath(config.folder, config.launchId);
|
|
321
|
+
await fs.promises.writeFile(tailScript, getLogTailScript(logPath));
|
|
322
|
+
await runPromise(`chmod +x ${tailScript}`);
|
|
323
|
+
|
|
324
|
+
// Read what is already there ourselves and deliver it as one call - letting the tail script cat it
|
|
325
|
+
// makes runInSerial dribble it out one round trip at a time.
|
|
326
|
+
let initialContent = "";
|
|
327
|
+
try {
|
|
328
|
+
initialContent = await fs.promises.readFile(logPath, "utf8");
|
|
329
|
+
} catch {
|
|
330
|
+
// The process may not have written anything yet; the tail script picks it up when it does.
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
childProcess = spawn("bash", [tailScript, String(Buffer.byteLength(initialContent, "utf8"))], {
|
|
334
|
+
stdio: "pipe",
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
let started = new PromiseObj<void>();
|
|
338
|
+
|
|
339
|
+
childProcess.stdout?.on("data", (data) => {
|
|
340
|
+
if (stopped) return;
|
|
341
|
+
started.resolve();
|
|
342
|
+
void onDataWrapped(data.toString());
|
|
343
|
+
});
|
|
344
|
+
// Give it some time to error out, otherwise, just start
|
|
345
|
+
setTimeout(() => started.resolve(), 200);
|
|
346
|
+
|
|
347
|
+
childProcess.stderr?.on("data", (data) => {
|
|
348
|
+
if (stopped) return;
|
|
349
|
+
void onDataWrapped(red(data.toString()));
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
childProcess.on("error", async (err) => {
|
|
353
|
+
if (stopped) return;
|
|
354
|
+
started.reject(err);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
if (initialContent) {
|
|
358
|
+
// Queued synchronously here, before any stdout "data" event can fire, so it always lands first
|
|
359
|
+
started.resolve();
|
|
360
|
+
void onDataWrapped(initialContent);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
await started.promise;
|
|
364
|
+
} catch (e) {
|
|
365
|
+
void stop();
|
|
366
|
+
throw e;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
@@ -3,7 +3,6 @@ import { lazy } from "socket-function/src/caching";
|
|
|
3
3
|
import { formatNumber } from "socket-function/src/formatting/format";
|
|
4
4
|
import { blue } from "socket-function/src/formatting/logColors";
|
|
5
5
|
import { isNode } from "socket-function/src/misc";
|
|
6
|
-
import { registerPeriodic } from "./periodic";
|
|
7
6
|
import { registerMeasureInfo } from "socket-function/src/profiling/measure";
|
|
8
7
|
import { logNodeStateStats } from "../-0-hooks/hooks";
|
|
9
8
|
|
|
@@ -89,4 +88,7 @@ registerMeasureInfo(() => {
|
|
|
89
88
|
return `MEM ${formatNumber(getUsedHeapSize())}B+${formatNumber(getBufferUsage())}B/${formatNumber(getHeapSize())}B `;
|
|
90
89
|
});
|
|
91
90
|
|
|
92
|
-
|
|
91
|
+
setImmediate(async () => {
|
|
92
|
+
let { registerPeriodic } = await import("./periodic");
|
|
93
|
+
registerPeriodic(logResourcesNow);
|
|
94
|
+
});
|