querysub 0.569.0 → 0.571.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/appSecrets.ts +21 -0
- package/package.json +1 -1
- package/src/deployManager/components/ProcessesView.tsx +5 -4
- package/src/deployManager/machineApplyMainCode.ts +227 -410
- package/src/deployManager/machineController.ts +15 -9
- package/src/deployManager/processLogs.ts +32 -41
- package/src/deployManager/processManager.ts +102 -88
|
@@ -83,16 +83,18 @@ class MachineControllerBase {
|
|
|
83
83
|
}
|
|
84
84
|
public async streamProcessOutput(config: {
|
|
85
85
|
folder: string;
|
|
86
|
-
|
|
86
|
+
pid: string;
|
|
87
|
+
startTime: number;
|
|
87
88
|
callbackId: string;
|
|
88
89
|
}): Promise<void> {
|
|
89
90
|
let caller = SocketFunction.getCaller();
|
|
90
91
|
await streamProcessOutput({
|
|
91
92
|
folder: config.folder,
|
|
92
|
-
|
|
93
|
+
pid: config.pid,
|
|
94
|
+
startTime: config.startTime,
|
|
93
95
|
onData: async (data) => {
|
|
94
96
|
await MachineControllerClient.nodes[caller.nodeId].onScreenOutput({
|
|
95
|
-
|
|
97
|
+
pid: config.pid,
|
|
96
98
|
data,
|
|
97
99
|
callbackId: config.callbackId,
|
|
98
100
|
});
|
|
@@ -106,14 +108,16 @@ class MachineControllerBase {
|
|
|
106
108
|
public async watchOtherProcessOutput(config: {
|
|
107
109
|
nodeId: string;
|
|
108
110
|
folder: string;
|
|
109
|
-
|
|
111
|
+
pid: string;
|
|
112
|
+
startTime: number;
|
|
110
113
|
callbackId: string;
|
|
111
114
|
}) {
|
|
112
115
|
let caller = SocketFunction.getCaller();
|
|
113
116
|
forwardedCallbacks.set(config.callbackId, caller.nodeId);
|
|
114
117
|
await MachineController(config.nodeId).streamProcessOutput.promise({
|
|
115
118
|
folder: config.folder,
|
|
116
|
-
|
|
119
|
+
pid: config.pid,
|
|
120
|
+
startTime: config.startTime,
|
|
117
121
|
callbackId: config.callbackId,
|
|
118
122
|
});
|
|
119
123
|
}
|
|
@@ -170,7 +174,8 @@ let callbacks = new Map<string, (data: string) => Promise<void>>();
|
|
|
170
174
|
export async function watchProcessOutput(config: {
|
|
171
175
|
nodeId: string;
|
|
172
176
|
folder: string;
|
|
173
|
-
|
|
177
|
+
pid: string;
|
|
178
|
+
startTime: number;
|
|
174
179
|
callbackId: string;
|
|
175
180
|
onData: (data: string) => Promise<void>;
|
|
176
181
|
}) {
|
|
@@ -179,7 +184,8 @@ export async function watchProcessOutput(config: {
|
|
|
179
184
|
await MachineController(SocketFunction.browserNodeId()).watchOtherProcessOutput.promise({
|
|
180
185
|
nodeId: config.nodeId,
|
|
181
186
|
folder: config.folder,
|
|
182
|
-
|
|
187
|
+
pid: config.pid,
|
|
188
|
+
startTime: config.startTime,
|
|
183
189
|
callbackId,
|
|
184
190
|
});
|
|
185
191
|
}
|
|
@@ -192,14 +198,14 @@ export async function stopWatchingProcessOutput(config: {
|
|
|
192
198
|
}
|
|
193
199
|
class MachineControllerClientBase {
|
|
194
200
|
public async onScreenOutput(config: {
|
|
195
|
-
|
|
201
|
+
pid: string;
|
|
196
202
|
data: string;
|
|
197
203
|
callbackId: string;
|
|
198
204
|
}): Promise<void> {
|
|
199
205
|
let forwardToNodeId = forwardedCallbacks.get(config.callbackId);
|
|
200
206
|
if (forwardToNodeId) {
|
|
201
207
|
await MachineControllerClient.nodes[forwardToNodeId].onScreenOutput({
|
|
202
|
-
|
|
208
|
+
pid: config.pid,
|
|
203
209
|
data: config.data,
|
|
204
210
|
callbackId: config.callbackId,
|
|
205
211
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import os from "os";
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import path from "path";
|
|
4
|
+
import { fsExistsAsync } from "../fs";
|
|
4
5
|
import { SERVICE_FOLDER, ServiceParameters } from "./machineSchema";
|
|
5
6
|
|
|
6
7
|
// 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.
|
|
@@ -16,53 +17,43 @@ const LOG_SUFFIX = ".log";
|
|
|
16
17
|
const PIPE_SCRIPT_SUFFIX = ".pipe.sh";
|
|
17
18
|
const TAIL_SCRIPT_SUFFIX = ".tail.sh";
|
|
18
19
|
|
|
19
|
-
/** One
|
|
20
|
+
/** One process: the configuration it was started with, and its output. A process is named by its pid and the start time the OS reports for that pid - the pid alone gets reused, and both halves can be re-checked against the machine at any time instead of trusted from our own bookkeeping. */
|
|
20
21
|
export type ProcessRecord = {
|
|
21
|
-
|
|
22
|
-
/** The
|
|
22
|
+
pid: string;
|
|
23
|
+
/** The start time the OS reports for the pid */
|
|
24
|
+
startTime: number;
|
|
25
|
+
/** The instance folder this process runs out of. A replacement shares the folder with the process it replaces, so this is what owns the logs, not the tmux session name. */
|
|
23
26
|
folder: string;
|
|
24
|
-
/** The tmux session, which a takeover renames
|
|
27
|
+
/** The tmux session, which a takeover renames */
|
|
25
28
|
screenName: string;
|
|
26
29
|
serviceId: string;
|
|
27
30
|
serviceKey: string;
|
|
28
31
|
/** Which instance of the service this is, on this machine */
|
|
29
32
|
index: number;
|
|
30
33
|
machineId: string;
|
|
31
|
-
/** The
|
|
32
|
-
startTime: number;
|
|
33
|
-
/** The node this process registered as, read out of the folder before any replacement is started - after that the file belongs to the replacement. Kept here so taking this process down needs nothing but its own record. */
|
|
34
|
+
/** The node this process registered as, read out of the folder before any replacement is started - after that the file belongs to the replacement. */
|
|
34
35
|
nodeId?: string;
|
|
35
36
|
/** When we first noticed the process was gone. Absent while it is running. */
|
|
36
37
|
deadTime?: number;
|
|
37
|
-
|
|
38
|
-
/** The parameters it was launched with, with template variables already resolved */
|
|
38
|
+
/** The parameters it was started with, with template variables already resolved */
|
|
39
39
|
parameters: ServiceParameters;
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
function getRecordPath(folder: string, launchId: string): string {
|
|
46
|
-
return path.join(folder, PROCESS_FOLDER, launchId + RECORD_SUFFIX);
|
|
42
|
+
// A process's files all share this name, so its whole footprint is found, and deleted, together
|
|
43
|
+
function getProcessFileName(pid: string, startTime: number): string {
|
|
44
|
+
return `${pid}-${startTime}`;
|
|
47
45
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return path.join(folder, PROCESS_FOLDER, launchId + PIPE_SCRIPT_SUFFIX);
|
|
46
|
+
export function getProcessLogPath(folder: string, pid: string, startTime: number): string {
|
|
47
|
+
return path.join(folder, PROCESS_FOLDER, getProcessFileName(pid, startTime) + LOG_SUFFIX);
|
|
51
48
|
}
|
|
52
|
-
export function
|
|
53
|
-
return path.join(folder, PROCESS_FOLDER,
|
|
49
|
+
export function getPipeScriptPath(folder: string, pid: string, startTime: number): string {
|
|
50
|
+
return path.join(folder, PROCESS_FOLDER, getProcessFileName(pid, startTime) + PIPE_SCRIPT_SUFFIX);
|
|
54
51
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
export function createLaunchId(pid: string, startTime: number): string {
|
|
58
|
-
return `${pid}-${startTime}`;
|
|
52
|
+
export function getTailScriptPath(folder: string, pid: string, startTime: number): string {
|
|
53
|
+
return path.join(folder, PROCESS_FOLDER, getProcessFileName(pid, startTime) + TAIL_SCRIPT_SUFFIX);
|
|
59
54
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return {
|
|
63
|
-
pid: launchId.slice(0, separator),
|
|
64
|
-
startTime: parseInt(launchId.slice(separator + 1)) || 0,
|
|
65
|
-
};
|
|
55
|
+
function getRecordPath(folder: string, pid: string, startTime: number): string {
|
|
56
|
+
return path.join(folder, PROCESS_FOLDER, getProcessFileName(pid, startTime) + RECORD_SUFFIX);
|
|
66
57
|
}
|
|
67
58
|
|
|
68
59
|
/** The shell a screen's output is piped through: appends every line, and empties the file once it grows past the limit. */
|
|
@@ -101,16 +92,16 @@ while true; do
|
|
|
101
92
|
done`;
|
|
102
93
|
}
|
|
103
94
|
|
|
104
|
-
/**
|
|
105
|
-
export async function
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
);
|
|
95
|
+
/** One process's record, read straight from the file its pid and start time name. */
|
|
96
|
+
export async function readProcessRecord(folder: string, pid: string, startTime: number): Promise<ProcessRecord | undefined> {
|
|
97
|
+
let file = getRecordPath(folder, pid, startTime);
|
|
98
|
+
if (!await fsExistsAsync(file)) return undefined;
|
|
99
|
+
return JSON.parse(await fs.promises.readFile(file, "utf8")) as ProcessRecord;
|
|
109
100
|
}
|
|
110
101
|
|
|
111
102
|
export async function writeProcessRecord(record: ProcessRecord): Promise<void> {
|
|
112
103
|
await fs.promises.mkdir(path.join(record.folder, PROCESS_FOLDER), { recursive: true });
|
|
113
|
-
await fs.promises.writeFile(getRecordPath(record.folder, record.
|
|
104
|
+
await fs.promises.writeFile(getRecordPath(record.folder, record.pid, record.startTime), JSON.stringify(record));
|
|
114
105
|
}
|
|
115
106
|
|
|
116
107
|
async function readFolderRecords(folder: string): Promise<ProcessRecord[]> {
|
|
@@ -147,10 +138,10 @@ export async function listProcessRecords(): Promise<ProcessRecord[]> {
|
|
|
147
138
|
|
|
148
139
|
async function removeProcessRecord(record: ProcessRecord): Promise<void> {
|
|
149
140
|
let files = [
|
|
150
|
-
getRecordPath(record.folder, record.
|
|
151
|
-
getProcessLogPath(record.folder, record.
|
|
152
|
-
getPipeScriptPath(record.folder, record.
|
|
153
|
-
getTailScriptPath(record.folder, record.
|
|
141
|
+
getRecordPath(record.folder, record.pid, record.startTime),
|
|
142
|
+
getProcessLogPath(record.folder, record.pid, record.startTime),
|
|
143
|
+
getPipeScriptPath(record.folder, record.pid, record.startTime),
|
|
144
|
+
getTailScriptPath(record.folder, record.pid, record.startTime),
|
|
154
145
|
];
|
|
155
146
|
for (let file of files) {
|
|
156
147
|
try {
|
|
@@ -169,11 +160,11 @@ export async function syncProcessRecords(config: {
|
|
|
169
160
|
for (let record of await listProcessRecords()) {
|
|
170
161
|
if (record.deadTime === undefined && !await config.isAlive(record)) {
|
|
171
162
|
record.deadTime = config.now;
|
|
172
|
-
console.log(`Process ${record.
|
|
163
|
+
console.log(`Process ${record.pid} (${record.screenName}) is no longer running, marking it dead. Its log is deleted at ${new Date(record.deadTime + DEAD_PROCESS_RETENTION).toISOString()}`);
|
|
173
164
|
await writeProcessRecord(record);
|
|
174
165
|
}
|
|
175
166
|
if (record.deadTime !== undefined && config.now - record.deadTime > DEAD_PROCESS_RETENTION) {
|
|
176
|
-
console.log(`Deleting the log of process ${record.
|
|
167
|
+
console.log(`Deleting the log of process ${record.pid} (${record.screenName}), dead since ${new Date(record.deadTime).toISOString()}`);
|
|
177
168
|
await removeProcessRecord(record);
|
|
178
169
|
}
|
|
179
170
|
}
|
|
@@ -2,7 +2,7 @@ import os from "os";
|
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { spawn, ChildProcess } from "child_process";
|
|
5
|
-
import { lazy } from "socket-function/src/caching";
|
|
5
|
+
import { cache, lazy } from "socket-function/src/caching";
|
|
6
6
|
import { measureWrap } from "socket-function/src/profiling/measure";
|
|
7
7
|
import { delay, runInSerial } from "socket-function/src/batching";
|
|
8
8
|
import { timeInSecond } from "socket-function/src/misc";
|
|
@@ -13,7 +13,7 @@ import { forceRemoveNode } from "../-f-node-discovery/NodeDiscovery";
|
|
|
13
13
|
import { fsExistsAsync } from "../fs";
|
|
14
14
|
import { PromiseObj } from "../promise";
|
|
15
15
|
import { SERVICE_FOLDER, SERVICE_NODE_FILE_NAME } from "./machineSchema";
|
|
16
|
-
import { ProcessRecord,
|
|
16
|
+
import { ProcessRecord, getLogPipeScript, getLogTailScript, getProcessLogPath, getPipeScriptPath, getTailScriptPath, readProcessRecord, writeProcessRecord } from "./processLogs";
|
|
17
17
|
import { setPreciseTimeout } from "../misc";
|
|
18
18
|
|
|
19
19
|
// 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.
|
|
@@ -25,9 +25,11 @@ const SCREEN_SUFFIX = "-dply";
|
|
|
25
25
|
export function getScreenName(config: { serviceKey: string; index: number }): string {
|
|
26
26
|
return `${config.serviceKey}-${config.index}${SCREEN_SUFFIX}`.replace(/[^a-zA-Z0-9\-_]/g, "_");
|
|
27
27
|
}
|
|
28
|
+
/** Marks a session that has already been handed off and is winding down on its own timer. */
|
|
29
|
+
export const RETIRING_MARKER = "-retiring-";
|
|
28
30
|
/** The name an outgoing screen is moved to so the new process can take the canonical name at once, instead of anything waiting for the old process to wind down. Keeps the suffix, so it is still a screen we know about. */
|
|
29
31
|
export function getRetiringScreenName(canonicalScreenName: string, at: number): string {
|
|
30
|
-
return canonicalScreenName.slice(0, -SCREEN_SUFFIX.length) +
|
|
32
|
+
return canonicalScreenName.slice(0, -SCREEN_SUFFIX.length) + RETIRING_MARKER + at + SCREEN_SUFFIX;
|
|
31
33
|
}
|
|
32
34
|
// 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.
|
|
33
35
|
export function getFutureScreenName(canonicalScreenName: string): string {
|
|
@@ -36,25 +38,29 @@ export function getFutureScreenName(canonicalScreenName: string): string {
|
|
|
36
38
|
|
|
37
39
|
|
|
38
40
|
// Everything that touches a service's screens - the resync loop, and the timer that retires the old process at its kill time - runs under this, keyed by the canonical screen name. They fire at similar times by design, and a takeover is a rename dance that must not interleave with another one.
|
|
39
|
-
const
|
|
40
|
-
export function withScreenLock<T>(canonicalScreenName: string, run: () => Promise<T>): Promise<T> {
|
|
41
|
-
|
|
42
|
-
let next = previous.then(run, run);
|
|
43
|
-
// Failures release the lock rather than poisoning every later operation on this screen
|
|
44
|
-
screenLocks.set(canonicalScreenName, next.then(() => { }, () => { }));
|
|
45
|
-
return next;
|
|
41
|
+
const getScreenLock = cache((canonicalScreenName: string) => runInSerial(async (run: () => Promise<unknown>) => await run()));
|
|
42
|
+
export async function withScreenLock<T>(canonicalScreenName: string, run: () => Promise<T>): Promise<T> {
|
|
43
|
+
return await getScreenLock(canonicalScreenName)(run) as T;
|
|
46
44
|
}
|
|
47
45
|
|
|
48
|
-
/**
|
|
49
|
-
async function
|
|
46
|
+
/** The pid of a screen's pane, or undefined when there is no such session. Asks about the one screen instead of listing every session on the machine. */
|
|
47
|
+
export async function getScreenPanePid(screenName: string): Promise<string | undefined> {
|
|
50
48
|
let prefix = getTmuxPrefix();
|
|
49
|
+
let output: string;
|
|
51
50
|
try {
|
|
52
|
-
await runPromise(`${prefix}tmux
|
|
53
|
-
return true;
|
|
51
|
+
output = await runPromise(`${prefix}tmux list-panes -t ${screenName} -F "#{pane_pid}"`, { quiet: true });
|
|
54
52
|
} catch {
|
|
55
|
-
//
|
|
56
|
-
return
|
|
53
|
+
// Exits non-zero for "no such session", which is an answer, not a failure
|
|
54
|
+
return undefined;
|
|
57
55
|
}
|
|
56
|
+
return output.trim().split("\n")[0].trim() || undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Whether a screen exists AND has a process running in it - the only sense in which a service is up. */
|
|
60
|
+
export async function isScreenUp(screenName: string): Promise<boolean> {
|
|
61
|
+
let panePid = await getScreenPanePid(screenName);
|
|
62
|
+
if (!panePid) return false;
|
|
63
|
+
return await isScreenRunningProcess(panePid);
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
/** The node the process in this folder registered as. Read it BEFORE starting a replacement in the same folder - once the replacement starts, the file is the replacement's. */
|
|
@@ -213,94 +219,76 @@ export const runScreenCommand = measureWrap(async function runScreenCommand(conf
|
|
|
213
219
|
command: string;
|
|
214
220
|
// Defaults to the folder derived from screenName; a future screen passes its canonical screen's folder
|
|
215
221
|
folder?: string;
|
|
216
|
-
// Identifies the process this
|
|
217
|
-
record: Omit<ProcessRecord, "
|
|
222
|
+
// Identifies the process this start creates, so its log and its configuration are stored against it
|
|
223
|
+
record: Omit<ProcessRecord, "pid" | "startTime" | "folder" | "screenName">;
|
|
218
224
|
}): Promise<string> {
|
|
219
225
|
let prefix = getTmuxPrefix();
|
|
220
226
|
let screenName = config.screenName;
|
|
221
227
|
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
console.log(red(`Removing the leftover session ${screenName} from a launch that never completed`));
|
|
228
|
+
// Always a brand new session, so the pane is always new - and the pane's pid is what makes this a distinct process with its own log. A session already under this name is debris from a start that never got taken over.
|
|
229
|
+
if (await getScreenPanePid(screenName)) {
|
|
230
|
+
console.log(red(`Removing the leftover session ${screenName} from a start that never completed`));
|
|
226
231
|
await runPromise(`${prefix}tmux kill-session -t ${screenName}`);
|
|
227
232
|
}
|
|
228
233
|
await runPromise(`${prefix}tmux new -s ${screenName} -d`);
|
|
229
234
|
|
|
230
235
|
let folder = config.folder || os.homedir() + "/" + SERVICE_FOLDER + screenName + "/";
|
|
231
236
|
|
|
232
|
-
//
|
|
233
|
-
let
|
|
234
|
-
if (
|
|
235
|
-
for (let record of await listProcessRecords()) {
|
|
236
|
-
if (record.folder !== folder || record.deadTime !== undefined || record.nodeId) continue;
|
|
237
|
-
await writeProcessRecord({ ...record, nodeId: outgoingNodeId });
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// The pane we are about to run the command in identifies the process: its pid, and the start time the OS reports for that pid. Both can be re-checked against the machine later, so nothing downstream has to trust our bookkeeping about what is still running. The session was just created, so this pid is always new.
|
|
242
|
-
let panePid = (await getScreenState(false)).find(x => x.screenName === screenName)?.pid;
|
|
243
|
-
if (!panePid) {
|
|
237
|
+
// The pane we are about to run the command in names 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 trusts our bookkeeping about what is still running.
|
|
238
|
+
let pid = await getScreenPanePid(screenName);
|
|
239
|
+
if (!pid) {
|
|
244
240
|
throw new Error(`Screen ${screenName} does not exist after creating it, so there is no process to run the command in`);
|
|
245
241
|
}
|
|
246
|
-
let startTime = await getProcessStartTime(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
launchId,
|
|
251
|
-
folder,
|
|
252
|
-
screenName,
|
|
253
|
-
pid: parseInt(panePid) || undefined,
|
|
254
|
-
startTime,
|
|
255
|
-
});
|
|
242
|
+
let startTime = await getProcessStartTime(pid);
|
|
243
|
+
await writeProcessRecord({ ...config.record, pid, startTime, folder, screenName });
|
|
244
|
+
// The pipe is attached before the command runs, so the log holds everything the process ever printed
|
|
245
|
+
await setupPipePane({ screenName, folder, pid, startTime });
|
|
256
246
|
await runPromise(`${prefix}tmux send-keys -t ${screenName} 'cd ${folder}git' Enter`);
|
|
257
247
|
let command = `#!/bin/bash
|
|
258
248
|
${config.command}
|
|
259
249
|
`;
|
|
260
250
|
await fs.promises.writeFile(folder + "command.sh", command);
|
|
251
|
+
// Sending the command is the LAST thing this does, so the caller's next line runs the instant the process starts
|
|
261
252
|
await runPromise(`${prefix}tmux send-keys -t ${screenName} 'bash ../command.sh' Enter`);
|
|
262
|
-
|
|
263
|
-
await setupPipePane({ screenName, folder, launchId });
|
|
264
|
-
return launchId;
|
|
253
|
+
return pid;
|
|
265
254
|
});
|
|
266
255
|
|
|
267
|
-
/** Links an already-running screen to a process record, creating one when this process has never been recorded - it was
|
|
256
|
+
/** Links an already-running screen to a process record, creating one when this process has never been recorded - it was started before we restarted, or before process records existed at all. Idempotent: a process we already know about is left alone, so the pipe is only attached the first time we adopt it.
|
|
268
257
|
*
|
|
269
|
-
* The screen
|
|
258
|
+
* The screen names the process itself (pane pid + the start time the OS reports for it), so adoption is just writing down what is already there. */
|
|
270
259
|
export async function ensureProcessRecord(config: {
|
|
271
260
|
screenName: string;
|
|
272
261
|
folder: string;
|
|
273
262
|
panePid: string;
|
|
274
|
-
record: Omit<ProcessRecord, "
|
|
263
|
+
record: Omit<ProcessRecord, "pid" | "startTime" | "folder" | "screenName">;
|
|
275
264
|
}): Promise<void> {
|
|
276
|
-
let
|
|
265
|
+
let pid = config.panePid;
|
|
266
|
+
let startTime = await getProcessStartTime(pid);
|
|
277
267
|
if (!startTime) return;
|
|
278
|
-
let
|
|
279
|
-
let existing = (await listProcessRecords()).find(x => x.launchId === launchId && x.folder === config.folder);
|
|
268
|
+
let existing = await readProcessRecord(config.folder, pid, startTime);
|
|
280
269
|
if (existing) {
|
|
281
270
|
// A takeover renames the session, so the record follows the name it now runs under
|
|
282
271
|
if (existing.screenName === config.screenName) return;
|
|
283
272
|
await writeProcessRecord({ ...existing, screenName: config.screenName });
|
|
284
273
|
return;
|
|
285
274
|
}
|
|
286
|
-
console.log(`Adopting already-running process ${
|
|
275
|
+
console.log(`Adopting already-running process ${pid} on screen ${config.screenName}, which has no record yet`);
|
|
287
276
|
await writeProcessRecord({
|
|
288
277
|
...config.record,
|
|
289
|
-
|
|
278
|
+
pid,
|
|
279
|
+
startTime,
|
|
290
280
|
folder: config.folder,
|
|
291
281
|
screenName: config.screenName,
|
|
292
|
-
pid: parseInt(config.panePid) || undefined,
|
|
293
|
-
startTime,
|
|
294
282
|
});
|
|
295
283
|
// Its output was going to whatever the previous incarnation pointed at (or nowhere), so point it at its own log from here on
|
|
296
|
-
await setupPipePane({ screenName: config.screenName, folder: config.folder,
|
|
284
|
+
await setupPipePane({ screenName: config.screenName, folder: config.folder, pid, startTime });
|
|
297
285
|
}
|
|
298
286
|
|
|
299
|
-
// Points the screen's pipe-pane at this
|
|
300
|
-
async function setupPipePane(config: { screenName: string; folder: string;
|
|
287
|
+
// Points the screen's pipe-pane at this process's own log file.
|
|
288
|
+
async function setupPipePane(config: { screenName: string; folder: string; pid: string; startTime: number }) {
|
|
301
289
|
let prefix = getTmuxPrefix();
|
|
302
|
-
let logPath = getProcessLogPath(config.folder, config.
|
|
303
|
-
let pipeScript = getPipeScriptPath(config.folder, config.
|
|
290
|
+
let logPath = getProcessLogPath(config.folder, config.pid, config.startTime);
|
|
291
|
+
let pipeScript = getPipeScriptPath(config.folder, config.pid, config.startTime);
|
|
304
292
|
await fs.promises.mkdir(path.dirname(logPath), { recursive: true });
|
|
305
293
|
await fs.promises.writeFile(pipeScript, getLogPipeScript(logPath));
|
|
306
294
|
await runPromise(`chmod +x ${pipeScript}`);
|
|
@@ -308,42 +296,61 @@ async function setupPipePane(config: { screenName: string; folder: string; launc
|
|
|
308
296
|
}
|
|
309
297
|
|
|
310
298
|
/** Retires whatever is running under the canonical name and renames the future screen onto it. Everything it needs is derived from the two names, so it costs two `has-session` checks - the kill time is when this runs, not when a resync gets around to it. Returns whether there was a future screen to take over. */
|
|
311
|
-
export async function takeoverScreen(config: {
|
|
312
|
-
|
|
299
|
+
export async function takeoverScreen(config: {
|
|
300
|
+
canonicalScreenName: string;
|
|
301
|
+
folder: string;
|
|
302
|
+
/** The node the version being replaced registered as, read before the replacement overwrote the folder's file. */
|
|
303
|
+
replacedNodeId?: string;
|
|
304
|
+
}): Promise<boolean> {
|
|
305
|
+
let { canonicalScreenName, folder, replacedNodeId } = config;
|
|
313
306
|
return await withScreenLock(canonicalScreenName, async () => {
|
|
307
|
+
let futurePid = await getScreenPanePid(getFutureScreenName(canonicalScreenName));
|
|
308
|
+
if (!futurePid) return false;
|
|
314
309
|
let futureScreenName = getFutureScreenName(canonicalScreenName);
|
|
315
|
-
if (!await screenExists(futureScreenName)) return false;
|
|
316
310
|
let prefix = getTmuxPrefix();
|
|
317
|
-
|
|
311
|
+
let outgoingPid = await getScreenPanePid(canonicalScreenName);
|
|
312
|
+
if (outgoingPid) {
|
|
313
|
+
// Moved aside rather than waited on, so the canonical name is free at once and the old process winds down on its own
|
|
318
314
|
let retiringScreenName = getRetiringScreenName(canonicalScreenName, Date.now());
|
|
319
|
-
// Its record carries the node it registered as, taken before the replacement started - the file in the folder belongs to the replacement now, so it is left alone
|
|
320
|
-
let outgoing = await findRunningRecord(folder, canonicalScreenName);
|
|
321
315
|
await runPromise(`${prefix}tmux rename-session -t ${canonicalScreenName} ${retiringScreenName}`);
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
}
|
|
316
|
+
await renameProcessScreen({ folder, panePid: outgoingPid, screenName: retiringScreenName });
|
|
317
|
+
if (replacedNodeId) {
|
|
318
|
+
// The file in the folder belongs to the replacement now, so only the registration goes
|
|
319
|
+
void removeServiceNode({ folder, nodeId: replacedNodeId, ownsFile: false });
|
|
327
320
|
}
|
|
328
|
-
|
|
329
|
-
void killScreen({ screenName: retiringScreenName })
|
|
321
|
+
retiringScreens.add(retiringScreenName);
|
|
322
|
+
void killScreen({ screenName: retiringScreenName }).finally(() => {
|
|
323
|
+
retiringScreens.delete(retiringScreenName);
|
|
324
|
+
});
|
|
330
325
|
}
|
|
331
|
-
console.log(green(`Renaming
|
|
326
|
+
console.log(green(`Renaming ${futureScreenName} to ${canonicalScreenName} (takeover complete)`));
|
|
332
327
|
await runPromise(`${prefix}tmux rename-session -t ${futureScreenName} ${canonicalScreenName}`);
|
|
333
|
-
|
|
334
|
-
if (incoming) {
|
|
335
|
-
await writeProcessRecord({ ...incoming, screenName: canonicalScreenName });
|
|
336
|
-
}
|
|
328
|
+
await renameProcessScreen({ folder, panePid: futurePid, screenName: canonicalScreenName });
|
|
337
329
|
return true;
|
|
338
330
|
});
|
|
339
331
|
}
|
|
340
332
|
|
|
333
|
+
/** Points a process's record at the session name it now runs under. The pid is the key, so this reads exactly one file. */
|
|
334
|
+
async function renameProcessScreen(config: { folder: string; panePid: string; screenName: string }): Promise<void> {
|
|
335
|
+
let startTime = await getProcessStartTime(config.panePid);
|
|
336
|
+
if (!startTime) return;
|
|
337
|
+
let record = await readProcessRecord(config.folder, config.panePid, startTime);
|
|
338
|
+
if (!record || record.screenName === config.screenName) return;
|
|
339
|
+
await writeProcessRecord({ ...record, screenName: config.screenName });
|
|
340
|
+
}
|
|
341
|
+
|
|
341
342
|
// canonical screen name -> the takeover we already have scheduled for it
|
|
342
343
|
const scheduledTakeovers = new Map<string, { time: number; cancel: () => void }>();
|
|
343
344
|
|
|
344
345
|
/** Schedules the takeover for the moment the old process's overlap runs out. The resync loop still does the same takeover whenever it notices the time has passed, which is what recovers this if we restart and lose the timer. */
|
|
345
|
-
export function scheduleTakeover(config: {
|
|
346
|
-
|
|
346
|
+
export function scheduleTakeover(config: {
|
|
347
|
+
canonicalScreenName: string;
|
|
348
|
+
folder: string;
|
|
349
|
+
killTime: number;
|
|
350
|
+
/** The node the version being replaced registered as, read before the replacement overwrote the folder's file. */
|
|
351
|
+
replacedNodeId?: string;
|
|
352
|
+
}): void {
|
|
353
|
+
let { canonicalScreenName, folder, killTime, replacedNodeId } = config;
|
|
347
354
|
let existing = scheduledTakeovers.get(canonicalScreenName);
|
|
348
355
|
if (existing?.time === killTime) return;
|
|
349
356
|
existing?.cancel();
|
|
@@ -352,12 +359,18 @@ export function scheduleTakeover(config: { canonicalScreenName: string; folder:
|
|
|
352
359
|
time: killTime,
|
|
353
360
|
callback: () => {
|
|
354
361
|
scheduledTakeovers.delete(canonicalScreenName);
|
|
355
|
-
void takeoverScreen({ canonicalScreenName, folder });
|
|
362
|
+
void takeoverScreen({ canonicalScreenName, folder, replacedNodeId });
|
|
356
363
|
},
|
|
357
364
|
});
|
|
358
365
|
scheduledTakeovers.set(canonicalScreenName, { time: killTime, cancel });
|
|
359
366
|
}
|
|
360
367
|
|
|
368
|
+
// The screens whose shutdown is already running. Nothing else should start a second one, but a screen left behind by a restart is NOT in here, so the unused-screen sweep still cleans it up.
|
|
369
|
+
const retiringScreens = new Set<string>();
|
|
370
|
+
export function isRetiring(screenName: string): boolean {
|
|
371
|
+
return retiringScreens.has(screenName);
|
|
372
|
+
}
|
|
373
|
+
|
|
361
374
|
// Kills a screen and nothing else. Which node the process registered as, and whether that registration should be dropped, is the deploy logic's business - it reads the node id off the process's record, which was taken before any replacement could overwrite the file.
|
|
362
375
|
export const killScreen = measureWrap(async function killScreen(config: {
|
|
363
376
|
screenName: string;
|
|
@@ -373,10 +386,11 @@ export const killScreen = measureWrap(async function killScreen(config: {
|
|
|
373
386
|
/** 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. */
|
|
374
387
|
export async function streamProcessOutput(config: {
|
|
375
388
|
folder: string;
|
|
376
|
-
|
|
389
|
+
pid: string;
|
|
390
|
+
startTime: number;
|
|
377
391
|
onData: (data: string) => Promise<void>;
|
|
378
392
|
}) {
|
|
379
|
-
let logPath = getProcessLogPath(config.folder, config.
|
|
393
|
+
let logPath = getProcessLogPath(config.folder, config.pid, config.startTime);
|
|
380
394
|
let serialOnData = runInSerial(config.onData);
|
|
381
395
|
let stopped = false;
|
|
382
396
|
let childProcess: ChildProcess | undefined;
|
|
@@ -395,14 +409,14 @@ export async function streamProcessOutput(config: {
|
|
|
395
409
|
const onDataWrapped = async (data: string) => {
|
|
396
410
|
pendingDataCalls++;
|
|
397
411
|
if (pendingDataCalls > MAX_PENDING_CALLS) {
|
|
398
|
-
console.error(`Too many queued onData calls for ${config.
|
|
412
|
+
console.error(`Too many queued onData calls for ${config.pid}, stopping stream.`);
|
|
399
413
|
await stop();
|
|
400
414
|
return;
|
|
401
415
|
}
|
|
402
416
|
try {
|
|
403
417
|
await serialOnData(data);
|
|
404
418
|
} catch (e: any) {
|
|
405
|
-
console.log(`Callback for stream output ${config.
|
|
419
|
+
console.log(`Callback for stream output ${config.pid} failed. It probably just disconnected, almost certainly not an error: ${e.message}`);
|
|
406
420
|
await stop();
|
|
407
421
|
} finally {
|
|
408
422
|
pendingDataCalls--;
|
|
@@ -410,7 +424,7 @@ export async function streamProcessOutput(config: {
|
|
|
410
424
|
};
|
|
411
425
|
|
|
412
426
|
try {
|
|
413
|
-
let tailScript = getTailScriptPath(config.folder, config.
|
|
427
|
+
let tailScript = getTailScriptPath(config.folder, config.pid, config.startTime);
|
|
414
428
|
await fs.promises.writeFile(tailScript, getLogTailScript(logPath));
|
|
415
429
|
await runPromise(`chmod +x ${tailScript}`);
|
|
416
430
|
|