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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.555.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.39",
74
+ "sliftutils": "^1.7.40",
75
75
  "socket-function": "^1.2.26",
76
76
  "terser": "^5.31.0",
77
77
  "typenode": "^6.6.1",
@@ -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, nextId, sort, timeInSecond } from "socket-function/src/misc";
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 { watchScreenOutput, stopWatchingScreenOutput } from "../machineController";
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 "../machineApplyMainCode";
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}>
@@ -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 { streamScreenOutput } from "./machineApplyMainCode";
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
- public async streamScreenOutput(config: {
76
- key: string;
77
- index: number;
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 streamScreenOutput({
82
- key: config.key,
83
- index: config.index,
84
- onData: async (data, dataConfig) => {
86
+ await streamProcessOutput({
87
+ folder: config.folder,
88
+ launchId: config.launchId,
89
+ onData: async (data) => {
85
90
  await MachineControllerClient.nodes[caller.nodeId].onScreenOutput({
86
- key: config.key,
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 it, as clients can't connect to servers directly (as they don't have real HTTP certificates).
96
- public async watchOtherScreenOutput(config: {
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
- key: string;
99
- index: number;
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).streamScreenOutput.promise({
105
- key: config.key,
106
- index: config.index,
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
- streamScreenOutput: {},
141
- watchOtherScreenOutput: {},
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, config?: { reset?: boolean }) => Promise<void>>();
157
- export async function watchScreenOutput(config: {
164
+ let callbacks = new Map<string, (data: string) => Promise<void>>();
165
+ export async function watchProcessOutput(config: {
158
166
  nodeId: string;
159
- key: string;
160
- index: number;
167
+ folder: string;
168
+ launchId: string;
161
169
  callbackId: string;
162
- /** reset means the process behind the screen changed: replace what you have with this, don't append it. */
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()).watchOtherScreenOutput.promise({
174
+ await MachineController(SocketFunction.browserNodeId()).watchOtherProcessOutput.promise({
168
175
  nodeId: config.nodeId,
169
- key: config.key,
170
- index: config.index,
176
+ folder: config.folder,
177
+ launchId: config.launchId,
171
178
  callbackId,
172
179
  });
173
180
  }
174
181
 
175
- export async function stopWatchingScreenOutput(config: {
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
- key: string;
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
- key: config.key,
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, { reset: config.reset });
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
- registerPeriodic(logResourcesNow);
91
+ setImmediate(async () => {
92
+ let { registerPeriodic } = await import("./periodic");
93
+ registerPeriodic(logResourcesNow);
94
+ });