querysub 0.570.0 → 0.572.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 +1 -0
- package/package.json +1 -1
- package/src/deployManager/components/ProcessesView.tsx +5 -4
- package/src/deployManager/machineApplyMainCode.ts +193 -189
- package/src/deployManager/machineController.ts +15 -9
- package/src/deployManager/processLogs.ts +32 -41
- package/src/deployManager/processManager.ts +123 -106
package/appSecrets.ts
CHANGED
package/package.json
CHANGED
|
@@ -32,14 +32,15 @@ class ProcessOutput extends qreact.Component<{ record: ProcessRecord; machineNod
|
|
|
32
32
|
private unmounted = false;
|
|
33
33
|
componentDidMount() {
|
|
34
34
|
// Props are synchronized state, so the plain values the async work needs are read here
|
|
35
|
-
let { folder,
|
|
35
|
+
let { folder, pid, startTime } = this.props.record;
|
|
36
36
|
let nodeId = this.props.machineNodeId;
|
|
37
37
|
let callbackId = this.callbackId;
|
|
38
38
|
this.watching = (async () => {
|
|
39
39
|
await watchProcessOutput({
|
|
40
40
|
nodeId,
|
|
41
41
|
folder,
|
|
42
|
-
|
|
42
|
+
pid,
|
|
43
|
+
startTime,
|
|
43
44
|
callbackId,
|
|
44
45
|
onData: async (data: string) => {
|
|
45
46
|
Querysub.commit(() => {
|
|
@@ -153,9 +154,9 @@ class MachineProcesses extends qreact.Component<{ machineId: string; applyNodeId
|
|
|
153
154
|
{this.state.showHistorical && `Hide ${historical.length} historical` || `Show ${historical.length} historical`}
|
|
154
155
|
</Button>}
|
|
155
156
|
</div>
|
|
156
|
-
{running.map(record => <ProcessRow key={record.
|
|
157
|
+
{running.map(record => <ProcessRow key={`${record.pid}-${record.startTime}`} record={record} machineNodeId={this.props.applyNodeId} />)}
|
|
157
158
|
{this.state.showHistorical && historical.map(record =>
|
|
158
|
-
<ProcessRow key={record.
|
|
159
|
+
<ProcessRow key={`${record.pid}-${record.startTime}`} record={record} machineNodeId={this.props.applyNodeId} />
|
|
159
160
|
)}
|
|
160
161
|
</div>;
|
|
161
162
|
}
|
|
@@ -10,7 +10,7 @@ import { getExternalIP } from "socket-function/src/networking";
|
|
|
10
10
|
import { errorToUndefined, errorToUndefinedSilent } from "../errors";
|
|
11
11
|
import { getDomain } from "../config";
|
|
12
12
|
import { formatTime } from "socket-function/src/formatting/format";
|
|
13
|
-
import { sort, timeInMinute, timeInSecond } from "socket-function/src/misc";
|
|
13
|
+
import { sort, timeInMinute, timeInSecond, watchSlowPromise } from "socket-function/src/misc";
|
|
14
14
|
import { isDefined, setPreciseTimeout } from "../misc";
|
|
15
15
|
import { logLoadTime } from "../logModuleLoadTimes";
|
|
16
16
|
import { delay, retryFunctional, runInSerial, runInfinitePoll, runInfinitePollCallAtStart } from "socket-function/src/batching";
|
|
@@ -26,8 +26,8 @@ import { PromiseObj } from "../promise";
|
|
|
26
26
|
import path from "path";
|
|
27
27
|
import { fsExistsAsync } from "../fs";
|
|
28
28
|
import { ALIVE_WINDOW_FOREVER, ParametersTimelineEntry, syncParametersTimelineFiles } from "./parametersTimeline";
|
|
29
|
-
import { getScreenName, getFutureScreenName,
|
|
30
|
-
import { ProcessRecord, syncProcessRecords,
|
|
29
|
+
import { getScreenName, getFutureScreenName, isRetiring, getTmuxPrefix, getScreenState, isScreenRunningProcess, getProcessStartTime, getScreenPanePid, ensureProcessRecord, runScreenCommand, killScreen, readServiceNodeId, removeServiceNode, takeoverScreenLocked, scheduleTakeover, killScreenNow, withScreenLock } from "./processManager";
|
|
30
|
+
import { ProcessRecord, syncProcessRecords, listProcessRecords } from "./processLogs";
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
|
|
@@ -222,87 +222,65 @@ let lastLaunchedTimePerService = new Map<string, number>();
|
|
|
222
222
|
|
|
223
223
|
const LAUNCH_VERIFY_DELAY = 2000;
|
|
224
224
|
|
|
225
|
-
/**
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
screenName: string;
|
|
229
|
-
/** The slot this belongs to - its own name, or the one it is replacing */
|
|
230
|
-
canonicalScreenName: string;
|
|
231
|
-
/** The instance folder, which is always the canonical screen's folder (an incoming version runs out of the same one) */
|
|
232
|
-
folder: string;
|
|
233
|
-
serviceId: string;
|
|
234
|
-
serviceKey: string;
|
|
235
|
-
index: number;
|
|
236
|
-
machineId: string;
|
|
237
|
-
/** The parameters it runs, with template variables already resolved */
|
|
238
|
-
parameters: ServiceParameters;
|
|
239
|
-
aliveWindow: [number, number];
|
|
240
|
-
/** The version being replaced during an overlap: it must keep running and must not be touched, because the folder already belongs to its replacement. */
|
|
241
|
-
outgoing?: boolean;
|
|
242
|
-
/** On an incoming version, when it takes the canonical name over. Absent means take over as soon as it is up. */
|
|
243
|
-
takeoverAt?: number;
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
/** Every screen that should exist on this machine, worked out purely from the configs and the clock. */
|
|
247
|
-
function getDesiredScreens(configs: ServiceConfig[], machineId: string, root: string): DesiredScreen[] {
|
|
248
|
-
let desired: DesiredScreen[] = [];
|
|
249
|
-
let now = Date.now();
|
|
225
|
+
/** Every instance of every service that belongs to this machine, with its config already resolved for that instance - the command has this entry's template variables substituted, and a version with no entry at this index is marked as deploying nothing here. */
|
|
226
|
+
function getMachineInstances(configs: ServiceConfig[], machineId: string): { config: ServiceConfig; index: number }[] {
|
|
227
|
+
let instances: { config: ServiceConfig; index: number }[] = [];
|
|
250
228
|
for (let config of configs) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
let
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
...extra,
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
};
|
|
278
|
-
|
|
279
|
-
if (releaseInFlight && config.oldParameters) {
|
|
280
|
-
// The old version keeps running under the canonical name until its overlap runs out
|
|
281
|
-
add(config.oldParameters, { outgoing: true, aliveWindow: [config.oldParameters.releaseTime || 0, killTime] });
|
|
282
|
-
// The new version runs alongside it from the release, and takes the canonical name at killTime
|
|
283
|
-
if (now >= releaseTime) {
|
|
284
|
-
add(config.parameters, { takeoverAt: killTime, aliveWindow: [releaseTime, ALIVE_WINDOW_FOREVER] });
|
|
285
|
-
}
|
|
286
|
-
} else {
|
|
287
|
-
add(getLiveServiceParameters(config), {});
|
|
229
|
+
// A release can add or remove instances, so the count is whichever version asks for the most
|
|
230
|
+
let count = 0;
|
|
231
|
+
for (let parameters of [config.parameters, config.oldParameters]) {
|
|
232
|
+
if (!parameters) continue;
|
|
233
|
+
count = Math.max(count, getMachineTargets(parameters).filter(x => x.machineId === machineId).length);
|
|
234
|
+
}
|
|
235
|
+
for (let index = 0; index < count; index++) {
|
|
236
|
+
const resolve = (parameters: ServiceParameters) => {
|
|
237
|
+
let target = getMachineTargets(parameters).filter(x => x.machineId === machineId)[index];
|
|
238
|
+
return {
|
|
239
|
+
...parameters,
|
|
240
|
+
command: target && applyCommandTemplate(parameters.command, target.variables) || parameters.command,
|
|
241
|
+
deploy: !!parameters.deploy && !!target,
|
|
242
|
+
};
|
|
243
|
+
};
|
|
244
|
+
instances.push({
|
|
245
|
+
index,
|
|
246
|
+
config: {
|
|
247
|
+
...config,
|
|
248
|
+
parameters: resolve(config.parameters),
|
|
249
|
+
oldParameters: config.oldParameters && resolve(config.oldParameters),
|
|
250
|
+
},
|
|
251
|
+
});
|
|
288
252
|
}
|
|
289
253
|
}
|
|
290
|
-
|
|
291
|
-
for (let screen of desired) {
|
|
292
|
-
if (screen.takeoverAt === undefined) continue;
|
|
293
|
-
screen.screenName = getFutureScreenName(screen.canonicalScreenName);
|
|
294
|
-
}
|
|
295
|
-
return desired;
|
|
254
|
+
return instances;
|
|
296
255
|
}
|
|
297
256
|
|
|
298
|
-
/** Puts one
|
|
257
|
+
/** Puts one instance of a service into the state its configuration describes: the folder synced, the process running what it should be, and the records and timeline saying so. Returns the screens it is using, so anything nobody claimed can be killed. Everything about one screen happens under its lock, so a resync and a scheduled takeover can never interleave. */
|
|
299
258
|
async function ensureScreen(config: {
|
|
300
|
-
|
|
301
|
-
|
|
259
|
+
config: ServiceConfig;
|
|
260
|
+
index: number;
|
|
302
261
|
machineInfo: MachineInfo;
|
|
303
|
-
}): Promise<
|
|
304
|
-
let {
|
|
305
|
-
let
|
|
262
|
+
}): Promise<string[]> {
|
|
263
|
+
let { machineInfo, index } = config;
|
|
264
|
+
let machineId = machineInfo.machineId;
|
|
265
|
+
let serviceId = config.config.serviceId;
|
|
266
|
+
let serviceConfig = config.config;
|
|
267
|
+
|
|
268
|
+
let releaseTime = serviceConfig.parameters.releaseTime || 0;
|
|
269
|
+
// The overlap length always comes from the NEWEST parameters, even though it governs how long the OLD instances outlive the release
|
|
270
|
+
let killTime = releaseTime + (serviceConfig.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME);
|
|
271
|
+
// Only in flight while there is an old version to keep alive, and only until its overlap runs out
|
|
272
|
+
let releaseInFlight = !!releaseTime && !!serviceConfig.oldParameters && Date.now() < killTime;
|
|
273
|
+
|
|
274
|
+
// What should be running under the canonical name now, and the version waiting to take it over
|
|
275
|
+
let current: ServiceParameters | undefined = releaseInFlight && serviceConfig.oldParameters || getLiveServiceParameters(serviceConfig);
|
|
276
|
+
if (!current?.deploy) current = undefined;
|
|
277
|
+
let incoming: ServiceParameters | undefined = releaseInFlight && Date.now() >= releaseTime && serviceConfig.parameters || undefined;
|
|
278
|
+
if (!incoming?.deploy) incoming = undefined;
|
|
279
|
+
let canonicalScreenName = getScreenName({ serviceKey: (current || incoming || serviceConfig.parameters).key, index });
|
|
280
|
+
let futureScreenName = getFutureScreenName(canonicalScreenName);
|
|
281
|
+
let folder = os.homedir() + "/" + SERVICE_FOLDER + canonicalScreenName + "/";
|
|
282
|
+
// Returned even when the work below throws, so a transient failure never makes the sweep kill a screen we still want
|
|
283
|
+
let usedScreenNames = [canonicalScreenName, futureScreenName];
|
|
306
284
|
|
|
307
285
|
let launchCount = launchesPerService.get(serviceId) || 0;
|
|
308
286
|
let lastLaunchedTime = lastLaunchedTimePerService.get(serviceId) || 0;
|
|
@@ -314,46 +292,70 @@ async function ensureScreen(config: {
|
|
|
314
292
|
};
|
|
315
293
|
|
|
316
294
|
try {
|
|
317
|
-
await withScreenLock(canonicalScreenName, async () => {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
295
|
+
await watchSlowPromise(`ensureScreen|${canonicalScreenName}`, withScreenLock(canonicalScreenName, async () => {
|
|
296
|
+
/* Three facts, each moving ? -> known -> correct as we go:
|
|
297
|
+
EXISTS - which of the two screens are there
|
|
298
|
+
VERSION - which parameters they were started with
|
|
299
|
+
RUNNING - whether a process is actually alive in them
|
|
300
|
+
Every step below says where those three stand, so it is always clear what we are still assuming.
|
|
301
|
+
START: EXISTS ? VERSION ? RUNNING ?
|
|
302
|
+
*/
|
|
303
|
+
let parameters = incoming || current;
|
|
304
|
+
let screenName = incoming && futureScreenName || canonicalScreenName;
|
|
305
|
+
let takeoverAt = incoming && killTime || undefined;
|
|
306
|
+
|
|
307
|
+
// A screen we do not expect holds something we never asked for, so it goes at once - nothing here is worth winding down. Each name is judged on its own: a release that turns deploy on has an incoming version and nothing that should be running under the canonical name yet.
|
|
308
|
+
// AFTER: EXISTS correct in the "nothing extra" sense. A screen that SHOULD exist may still be missing, which is RUNNING's problem below.
|
|
309
|
+
if (!incoming) {
|
|
310
|
+
await killScreenNow(futureScreenName, "there is no release in flight, so nothing should be waiting to take over");
|
|
333
311
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
await fs.promises.mkdir(gitFolder, { recursive: true });
|
|
337
|
-
if (parameters.gitRef) {
|
|
338
|
-
let prevGitRef = "";
|
|
339
|
-
try {
|
|
340
|
-
prevGitRef = await getGitRefLive(gitFolder);
|
|
341
|
-
} catch { }
|
|
342
|
-
await ensureGitSynced({ gitFolder, repoUrl: parameters.repoUrl, gitRef: parameters.gitRef });
|
|
343
|
-
let afterGitRef = await getGitRefLive(gitFolder);
|
|
344
|
-
// Reinstall when the ref changed OR node_modules is missing. A recovery re-clone can land on the same commit, so a ref-only check would leave the service with no node_modules.
|
|
345
|
-
let nodeModulesMissing = !await fsExistsAsync(gitFolder + "node_modules");
|
|
346
|
-
if (afterGitRef !== prevGitRef || nodeModulesMissing) {
|
|
347
|
-
console.log(green(`Yarn installing for ${magenta(screenName)} (ref ${prevGitRef} -> ${afterGitRef}, nodeModulesMissing=${nodeModulesMissing})`));
|
|
348
|
-
await runPromise(`yarn install --mutex network`, { cwd: gitFolder });
|
|
349
|
-
}
|
|
312
|
+
if (!current) {
|
|
313
|
+
await killScreenNow(canonicalScreenName, "nothing is supposed to be serving this name yet");
|
|
350
314
|
}
|
|
315
|
+
if (!current && !incoming) return;
|
|
351
316
|
|
|
317
|
+
// Both screens run out of one folder, so its parameters.json is the ONE version they were last started from - it cannot tell the two screens apart.
|
|
318
|
+
// AFTER: VERSION known. Anything it does not match, including a version we no longer recognize, is handled by starting the right one further down - which keeps whatever is up serving until the replacement is verified, instead of killing it now and serving nothing while we sync and start.
|
|
352
319
|
let parameterPath = folder + "parameters.json";
|
|
353
320
|
let prevParameters = "";
|
|
354
321
|
if (await fsExistsAsync(parameterPath)) {
|
|
355
322
|
prevParameters = await fs.promises.readFile(parameterPath, "utf8");
|
|
356
323
|
}
|
|
324
|
+
|
|
325
|
+
// AFTER: RUNNING known for every name that can still have anything in it. A screen can exist with nothing alive in it, and one that should exist can be missing entirely - both read as not up, and both are fixed the same way. Without an incoming version the future name was just killed, so there is nothing left to ask about it.
|
|
326
|
+
let canonicalPid = await getScreenPanePid(canonicalScreenName);
|
|
327
|
+
let canonicalUp = !!canonicalPid && await isScreenRunningProcess(canonicalPid);
|
|
328
|
+
let futurePid = incoming && await getScreenPanePid(futureScreenName) || undefined;
|
|
329
|
+
let futureUp = !!futurePid && await isScreenRunningProcess(futurePid);
|
|
330
|
+
|
|
331
|
+
// SMOOTH PATH: this is what a deploy looks like from the canonical name's side. The version being replaced is already correct - it keeps running untouched until its overlap ends, so this name is settled and only the future one is left to converge. It needs its record kept straight, and to hear when it goes down.
|
|
332
|
+
// AFTER (canonical, during a release): EXISTS correct VERSION correct RUNNING correct
|
|
333
|
+
if (incoming && current && !canonicalUp) {
|
|
334
|
+
console.log(red(`Nothing is running as ${canonicalScreenName}, which should be serving until ${new Date(killTime).toISOString()} while its replacement starts`));
|
|
335
|
+
}
|
|
336
|
+
if (incoming && current && canonicalUp && canonicalPid) {
|
|
337
|
+
await ensureProcessRecord({
|
|
338
|
+
screenName: canonicalScreenName,
|
|
339
|
+
folder,
|
|
340
|
+
panePid: canonicalPid,
|
|
341
|
+
record: { serviceId, serviceKey: current.key, index, machineId, parameters: current },
|
|
342
|
+
});
|
|
343
|
+
void (async () => {
|
|
344
|
+
let nodeId = await readServiceNodeId(folder);
|
|
345
|
+
if (nodeId) {
|
|
346
|
+
await notifyNodeShutdown(canonicalScreenName, nodeId, killTime);
|
|
347
|
+
}
|
|
348
|
+
})();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// From here on this is about ONE screen - the future one during a release, the canonical one otherwise. Everything else is already correct.
|
|
352
|
+
// STATE: EXISTS correct VERSION known RUNNING known
|
|
353
|
+
if (!parameters) return;
|
|
354
|
+
let panePid = incoming && futurePid || canonicalPid;
|
|
355
|
+
let isUp = incoming && futureUp || canonicalUp;
|
|
356
|
+
let aliveWindow: [number, number] = [parameters.releaseTime || 0, ALIVE_WINDOW_FOREVER];
|
|
357
|
+
let record = { serviceId, serviceKey: parameters.key, index, machineId, parameters };
|
|
358
|
+
|
|
357
359
|
let newParametersString = JSON.stringify(parameters);
|
|
358
360
|
|
|
359
361
|
let nodePathId = folder + SERVICE_NODE_FILE_NAME;
|
|
@@ -361,69 +363,112 @@ async function ensureScreen(config: {
|
|
|
361
363
|
machineInfo.services[serviceId].nodeId = await fs.promises.readFile(nodePathId, "utf8");
|
|
362
364
|
}
|
|
363
365
|
|
|
364
|
-
|
|
366
|
+
// Already running this exact version, so nothing needs starting - this is the state we are trying to reach
|
|
367
|
+
// AFTER: EXISTS correct VERSION correct RUNNING correct, and the takeover below settles which name it answers to
|
|
368
|
+
if (isUp && panePid && sameRestartParameters(prevParameters, parameters)) {
|
|
365
369
|
if (prevParameters !== newParametersString) {
|
|
366
370
|
await fs.promises.writeFile(parameterPath, newParametersString);
|
|
367
371
|
}
|
|
368
|
-
// We may not have
|
|
369
|
-
await ensureProcessRecord({ screenName, folder, panePid
|
|
370
|
-
await syncParametersTimelineFiles(folder, [{ pid: parseInt(
|
|
372
|
+
// We may not have started this one (we restarted, or it predates process records), so make sure it is recorded before leaving it alone
|
|
373
|
+
await ensureProcessRecord({ screenName, folder, panePid, record });
|
|
374
|
+
await syncParametersTimelineFiles(folder, [{ pid: parseInt(panePid) || undefined, aliveWindow, parameters }]);
|
|
375
|
+
if (takeoverAt !== undefined) {
|
|
376
|
+
if (!canonicalUp) {
|
|
377
|
+
// The version this one is waiting to replace is gone, so there is no overlap left to serve - take the name now instead of sitting under the future one
|
|
378
|
+
console.log(red(`Nothing is running as ${canonicalScreenName}, so ${screenName} takes it over now instead of waiting until ${new Date(takeoverAt).toISOString()}`));
|
|
379
|
+
await takeoverScreenLocked({ canonicalScreenName, folder });
|
|
380
|
+
} else {
|
|
381
|
+
// SMOOTH PATH: both versions are up, the old one serving and the new one waiting. A restart loses the timer that retires the old one, so it is put back every time we see it still waiting.
|
|
382
|
+
scheduleTakeover({ canonicalScreenName, folder, killTime: takeoverAt });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
371
385
|
console.log(green(`Verified ${magenta(screenName)} is running`));
|
|
372
386
|
return;
|
|
373
387
|
}
|
|
374
388
|
|
|
375
|
-
|
|
389
|
+
// RUNNING is wrong - either nothing is alive, or what is alive is the wrong version. Starting is the only thing that fixes it.
|
|
390
|
+
if (!isUp) {
|
|
391
|
+
// Nothing should ever stop on its own: either it crashed, or it never came up
|
|
392
|
+
console.log(red(`Nothing is running as ${screenName}, starting it: ${newParametersString}`));
|
|
393
|
+
} else {
|
|
394
|
+
// SMOOTH PATH: the previous version is up and this replaces it with a new one, which is exactly what a deploy is
|
|
395
|
+
console.log(green(`Starting a new version as ${magenta(screenName)}, replacing the one running there: ${newParametersString}`));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Only now does the folder have to hold this version, which is why the git and yarn work sits behind the check above rather than running on every resync
|
|
399
|
+
let gitFolder = folder + "git/";
|
|
400
|
+
await fs.promises.mkdir(gitFolder, { recursive: true });
|
|
401
|
+
if (parameters.gitRef) {
|
|
402
|
+
let prevGitRef = "";
|
|
403
|
+
try {
|
|
404
|
+
prevGitRef = await getGitRefLive(gitFolder);
|
|
405
|
+
} catch { }
|
|
406
|
+
await ensureGitSynced({ gitFolder, repoUrl: parameters.repoUrl, gitRef: parameters.gitRef });
|
|
407
|
+
let afterGitRef = await getGitRefLive(gitFolder);
|
|
408
|
+
// Reinstall when the ref changed OR node_modules is missing. A recovery re-clone can land on the same commit, so a ref-only check would leave the service with no node_modules.
|
|
409
|
+
let nodeModulesMissing = !await fsExistsAsync(gitFolder + "node_modules");
|
|
410
|
+
if (afterGitRef !== prevGitRef || nodeModulesMissing) {
|
|
411
|
+
console.log(green(`Yarn installing for ${magenta(screenName)} (ref ${prevGitRef} -> ${afterGitRef}, nodeModulesMissing=${nodeModulesMissing})`));
|
|
412
|
+
await runPromise(`yarn install --mutex network`, { cwd: gitFolder });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
376
416
|
await fs.promises.writeFile(parameterPath, newParametersString);
|
|
377
417
|
void recordLaunch({
|
|
378
418
|
serviceId,
|
|
379
|
-
serviceKey:
|
|
419
|
+
serviceKey: parameters.key,
|
|
380
420
|
screenName: canonicalScreenName,
|
|
381
|
-
machineId
|
|
382
|
-
reason:
|
|
421
|
+
machineId,
|
|
422
|
+
reason: isUp && "update" || "crashed",
|
|
383
423
|
time: Date.now(),
|
|
384
424
|
});
|
|
385
425
|
|
|
386
|
-
//
|
|
387
|
-
let
|
|
388
|
-
|
|
426
|
+
// The node the version being replaced registered as, read before the launch overwrites the file with the new process's
|
|
427
|
+
let replacedNodeId = await readServiceNodeId(folder);
|
|
428
|
+
|
|
429
|
+
// Always started under the future name and renamed over the canonical one, releases and plain restarts alike: it is up before anything touches what it replaces, and it always gets a brand new pane - which is what makes it a distinct process with its own log.
|
|
430
|
+
let launchScreenName = futureScreenName;
|
|
431
|
+
let launchedPid = await runScreenCommand({
|
|
389
432
|
screenName: launchScreenName,
|
|
390
433
|
folder,
|
|
391
434
|
command: parameters.command,
|
|
392
|
-
record
|
|
393
|
-
// Scheduled the instant the command is sent. Without a time the old process goes as soon as this one is verified.
|
|
394
|
-
takeover: desired.takeoverAt !== undefined && { canonicalScreenName, killTime: desired.takeoverAt } || undefined,
|
|
435
|
+
record,
|
|
395
436
|
});
|
|
437
|
+
// SMOOTH PATH: the command is running as of the line above, so the overlap the version it replaces gets is measured from here
|
|
438
|
+
if (takeoverAt !== undefined) {
|
|
439
|
+
scheduleTakeover({ canonicalScreenName, folder, killTime: takeoverAt, replacedNodeId });
|
|
440
|
+
}
|
|
396
441
|
|
|
397
442
|
await delay(LAUNCH_VERIFY_DELAY);
|
|
398
|
-
let
|
|
399
|
-
|
|
400
|
-
if (!launched || !isRunning) {
|
|
443
|
+
let launchedPanePid = await getScreenPanePid(launchScreenName);
|
|
444
|
+
if (!launchedPanePid || !await isScreenRunningProcess(launchedPanePid)) {
|
|
401
445
|
let prefix = getTmuxPrefix();
|
|
402
446
|
let logs = await runPromise(`${prefix}tmux capture-pane -t ${launchScreenName} -p`);
|
|
403
447
|
throw new Error(`${logs}\n\nService ${magenta(launchScreenName)} is not running after starting. Trying again in ${formatTime(MACHINE_RESYNC_INTERVAL)}, or on next change. Last logs above.`);
|
|
404
448
|
}
|
|
405
|
-
console.log(green(`${magenta(launchScreenName)} is verified to be running (
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
449
|
+
console.log(green(`${magenta(launchScreenName)} is verified to be running (pid ${launchedPid})`));
|
|
450
|
+
// Take the canonical name now unless something is still serving under it - waiting out an overlap that nothing is serving would leave the name empty for no reason. SMOOTH PATH during a release is neither: the version being replaced is still up, so this one waits under the future name until its timer fires.
|
|
451
|
+
// AFTER: EXISTS correct VERSION correct RUNNING correct, under the canonical name, or under the future one with the version it is replacing still serving
|
|
452
|
+
if (takeoverAt === undefined || !canonicalUp) {
|
|
453
|
+
await takeoverScreenLocked({ canonicalScreenName, folder, replacedNodeId });
|
|
410
454
|
}
|
|
411
|
-
await syncParametersTimelineFiles(folder, [{ pid: parseInt(
|
|
455
|
+
await syncParametersTimelineFiles(folder, [{ pid: parseInt(launchedPanePid) || undefined, aliveWindow, parameters }]);
|
|
412
456
|
|
|
413
457
|
launchCount++;
|
|
414
458
|
lastLaunchedTime = Date.now();
|
|
415
|
-
});
|
|
459
|
+
}));
|
|
416
460
|
} catch (e: any) {
|
|
417
461
|
// Counted on failure too, as a service that keeps crashing is only visible as a launch count climbing faster than everyone else's
|
|
418
462
|
launchCount++;
|
|
419
463
|
lastLaunchedTime = Date.now();
|
|
420
|
-
console.error(`Error resyncing ${magenta(
|
|
464
|
+
console.error(`Error resyncing ${magenta(canonicalScreenName)} (${serviceId}): ${e.stack}`);
|
|
421
465
|
machineInfo.services[serviceId].errorFromLastRun = e.stack;
|
|
422
466
|
}
|
|
423
467
|
machineInfo.services[serviceId].totalTimesLaunched = launchCount;
|
|
424
468
|
machineInfo.services[serviceId].lastLaunchedTime = lastLaunchedTime;
|
|
425
469
|
lastLaunchedTimePerService.set(serviceId, lastLaunchedTime);
|
|
426
470
|
launchesPerService.set(serviceId, launchCount);
|
|
471
|
+
return usedScreenNames;
|
|
427
472
|
}
|
|
428
473
|
|
|
429
474
|
function sameRestartParameters(prevParametersJSON: string, next: ServiceParameters): boolean {
|
|
@@ -445,46 +490,36 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
|
|
|
445
490
|
|
|
446
491
|
let machineInfo = await getLiveMachineInfo();
|
|
447
492
|
|
|
493
|
+
// The whole machine's screens, which is what the record maintenance and the kill sweep are about. Converging a screen asks about that one screen itself.
|
|
448
494
|
let screenState = await getScreenState();
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
let panePids = new Set(screenState.map(x => x.pid));
|
|
495
|
+
// A process is alive while its pane still exists, still reports the start time it was named after (a reused pid is a different process), and still has something running in it - a pane whose command exited is a dead process, not a live one
|
|
496
|
+
let running = new Set(screenState.filter(x => x.isProcessRunning).map(x => x.pid));
|
|
452
497
|
await syncProcessRecords({
|
|
453
498
|
isAlive: async record => {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
return await getProcessStartTime(pid) === record.startTime;
|
|
499
|
+
if (!running.has(record.pid)) return false;
|
|
500
|
+
return await getProcessStartTime(record.pid) === record.startTime;
|
|
457
501
|
},
|
|
458
502
|
now: Date.now(),
|
|
459
503
|
});
|
|
460
504
|
let root = os.homedir() + "/" + SERVICE_FOLDER;
|
|
461
|
-
let
|
|
505
|
+
let instances = getMachineInstances(relevantConfigs, machineId);
|
|
462
506
|
|
|
463
|
-
//
|
|
507
|
+
// The fast-path results are memoized per resync, so a folder shared by two instances is only traversed once
|
|
464
508
|
gitFastPathChecks.clear();
|
|
465
|
-
for (let desired of desiredScreens) {
|
|
466
|
-
if (desired.outgoing || !desired.parameters.gitRef) continue;
|
|
467
|
-
void checkGitSyncedAndClean(desired.folder + "git/", desired.parameters.gitRef);
|
|
468
|
-
}
|
|
469
509
|
|
|
470
|
-
// Every
|
|
471
|
-
await Promise.all(
|
|
510
|
+
// Every instance converges concurrently. Each has its own folder, its own records and its own lock, and yarn install takes a network mutex, so there is nothing to serialize behind - one slow git sync or shutdown no longer delays everything after it.
|
|
511
|
+
let usedScreenNames = new Set((await Promise.all(instances.map(instance => ensureScreen({ ...instance, machineInfo })))).flat());
|
|
472
512
|
|
|
473
|
-
//
|
|
474
|
-
let wantedNames = new Set<string>();
|
|
475
|
-
for (let desired of desiredScreens) {
|
|
476
|
-
wantedNames.add(desired.screenName);
|
|
477
|
-
wantedNames.add(desired.canonicalScreenName);
|
|
478
|
-
}
|
|
513
|
+
// Anything no instance claimed is not supposed to exist
|
|
479
514
|
for (let { screenName } of screenState) {
|
|
480
|
-
if (
|
|
481
|
-
//
|
|
482
|
-
if (screenName
|
|
515
|
+
if (usedScreenNames.has(screenName)) continue;
|
|
516
|
+
// Already being taken down. One left over from a restart is not, so it still gets cleaned up here.
|
|
517
|
+
if (isRetiring(screenName)) continue;
|
|
483
518
|
// Detached, so a screen that takes its full grace period to go down doesn't hold up the end of the resync
|
|
484
519
|
void (async () => {
|
|
485
520
|
// Nothing is replacing this one, so its registration and the file it wrote both go
|
|
486
521
|
let folder = root + screenName + "/";
|
|
487
|
-
let nodeId =
|
|
522
|
+
let nodeId = await readServiceNodeId(folder);
|
|
488
523
|
if (nodeId) {
|
|
489
524
|
await removeServiceNode({ folder, nodeId, ownsFile: true });
|
|
490
525
|
}
|
|
@@ -492,9 +527,6 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
|
|
|
492
527
|
})();
|
|
493
528
|
}
|
|
494
529
|
|
|
495
|
-
// The takeover is scheduled the moment the replacement starts, so this only exists to catch up after a restart loses those timers
|
|
496
|
-
scheduleReleaseResync(desiredScreens);
|
|
497
|
-
|
|
498
530
|
await machineInfos.set(machineId, machineInfo);
|
|
499
531
|
console.log(`${magenta(`Resynced ${relevantConfigs.length} services`)}:\n${JSON.stringify(machineInfo, null, 2)}`);
|
|
500
532
|
|
|
@@ -509,34 +541,6 @@ async function resyncServices() {
|
|
|
509
541
|
}
|
|
510
542
|
}
|
|
511
543
|
|
|
512
|
-
// The regular resync poll is too coarse for a takeover boundary, so schedule an extra resync at the next one. It carries no logic of its own — the resync just re-evaluates the current state, which is what recovers a takeover whose timer we lost by restarting.
|
|
513
|
-
let cancelReleaseResync: (() => void) | undefined;
|
|
514
|
-
function scheduleReleaseResync(desiredScreens: DesiredScreen[]) {
|
|
515
|
-
let now = Date.now();
|
|
516
|
-
let next: number | undefined;
|
|
517
|
-
for (let desired of desiredScreens) {
|
|
518
|
-
let boundary = desired.takeoverAt;
|
|
519
|
-
if (boundary === undefined || boundary <= now) continue;
|
|
520
|
-
if (next === undefined || boundary < next) {
|
|
521
|
-
next = boundary;
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
if (cancelReleaseResync) {
|
|
525
|
-
cancelReleaseResync();
|
|
526
|
-
cancelReleaseResync = undefined;
|
|
527
|
-
}
|
|
528
|
-
if (next === undefined) return;
|
|
529
|
-
console.log(`Scheduling a resync for the next release boundary at ${new Date(next).toLocaleString()}`);
|
|
530
|
-
cancelReleaseResync = setPreciseTimeout({
|
|
531
|
-
time: next,
|
|
532
|
-
callback: () => {
|
|
533
|
-
cancelReleaseResync = undefined;
|
|
534
|
-
void resyncServices();
|
|
535
|
-
},
|
|
536
|
-
});
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
|
|
540
544
|
async function isPIDRunning(pid: string) {
|
|
541
545
|
try {
|
|
542
546
|
// Use process.kill with signal 0 to test if process exists without actually killing it
|
|
@@ -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.
|
|
@@ -38,25 +38,29 @@ export function getFutureScreenName(canonicalScreenName: string): string {
|
|
|
38
38
|
|
|
39
39
|
|
|
40
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.
|
|
41
|
-
const
|
|
42
|
-
export function withScreenLock<T>(canonicalScreenName: string, run: () => Promise<T>): Promise<T> {
|
|
43
|
-
|
|
44
|
-
let next = previous.then(run, run);
|
|
45
|
-
// Failures release the lock rather than poisoning every later operation on this screen
|
|
46
|
-
screenLocks.set(canonicalScreenName, next.then(() => { }, () => { }));
|
|
47
|
-
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;
|
|
48
44
|
}
|
|
49
45
|
|
|
50
|
-
/**
|
|
51
|
-
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> {
|
|
52
48
|
let prefix = getTmuxPrefix();
|
|
49
|
+
let output: string;
|
|
53
50
|
try {
|
|
54
|
-
await runPromise(`${prefix}tmux
|
|
55
|
-
return true;
|
|
51
|
+
output = await runPromise(`${prefix}tmux list-panes -t ${screenName} -F "#{pane_pid}"`, { quiet: true });
|
|
56
52
|
} catch {
|
|
57
|
-
//
|
|
58
|
-
return
|
|
53
|
+
// Exits non-zero for "no such session", which is an answer, not a failure
|
|
54
|
+
return undefined;
|
|
59
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);
|
|
60
64
|
}
|
|
61
65
|
|
|
62
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. */
|
|
@@ -215,104 +219,76 @@ export const runScreenCommand = measureWrap(async function runScreenCommand(conf
|
|
|
215
219
|
command: string;
|
|
216
220
|
// Defaults to the folder derived from screenName; a future screen passes its canonical screen's folder
|
|
217
221
|
folder?: string;
|
|
218
|
-
// Identifies the process this
|
|
219
|
-
record: Omit<ProcessRecord, "
|
|
220
|
-
// The screen this launch replaces, and when that one's overlap runs out. The timer is set the instant the command is sent, so the overlap is measured from the new process actually starting.
|
|
221
|
-
takeover?: { canonicalScreenName: string; killTime: number };
|
|
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">;
|
|
222
224
|
}): Promise<string> {
|
|
223
225
|
let prefix = getTmuxPrefix();
|
|
224
226
|
let screenName = config.screenName;
|
|
225
227
|
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
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`));
|
|
230
231
|
await runPromise(`${prefix}tmux kill-session -t ${screenName}`);
|
|
231
232
|
}
|
|
232
233
|
await runPromise(`${prefix}tmux new -s ${screenName} -d`);
|
|
233
234
|
|
|
234
235
|
let folder = config.folder || os.homedir() + "/" + SERVICE_FOLDER + screenName + "/";
|
|
235
236
|
|
|
236
|
-
//
|
|
237
|
-
let
|
|
238
|
-
if (
|
|
239
|
-
for (let record of await listProcessRecords()) {
|
|
240
|
-
if (record.folder !== folder || record.deadTime !== undefined || record.nodeId) continue;
|
|
241
|
-
await writeProcessRecord({ ...record, nodeId: outgoingNodeId });
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// 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.
|
|
246
|
-
let panePid = (await getScreenState(false)).find(x => x.screenName === screenName)?.pid;
|
|
247
|
-
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) {
|
|
248
240
|
throw new Error(`Screen ${screenName} does not exist after creating it, so there is no process to run the command in`);
|
|
249
241
|
}
|
|
250
|
-
let startTime = await getProcessStartTime(
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
launchId,
|
|
255
|
-
folder,
|
|
256
|
-
screenName,
|
|
257
|
-
pid: parseInt(panePid) || undefined,
|
|
258
|
-
startTime,
|
|
259
|
-
});
|
|
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 });
|
|
260
246
|
await runPromise(`${prefix}tmux send-keys -t ${screenName} 'cd ${folder}git' Enter`);
|
|
261
247
|
let command = `#!/bin/bash
|
|
262
248
|
${config.command}
|
|
263
249
|
`;
|
|
264
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
|
|
265
252
|
await runPromise(`${prefix}tmux send-keys -t ${screenName} 'bash ../command.sh' Enter`);
|
|
266
|
-
|
|
267
|
-
if (config.takeover) {
|
|
268
|
-
scheduleTakeover({
|
|
269
|
-
canonicalScreenName: config.takeover.canonicalScreenName,
|
|
270
|
-
folder,
|
|
271
|
-
killTime: config.takeover.killTime,
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
await setupPipePane({ screenName, folder, launchId });
|
|
276
|
-
return launchId;
|
|
253
|
+
return pid;
|
|
277
254
|
});
|
|
278
255
|
|
|
279
|
-
/** 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.
|
|
280
257
|
*
|
|
281
|
-
* 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. */
|
|
282
259
|
export async function ensureProcessRecord(config: {
|
|
283
260
|
screenName: string;
|
|
284
261
|
folder: string;
|
|
285
262
|
panePid: string;
|
|
286
|
-
record: Omit<ProcessRecord, "
|
|
263
|
+
record: Omit<ProcessRecord, "pid" | "startTime" | "folder" | "screenName">;
|
|
287
264
|
}): Promise<void> {
|
|
288
|
-
let
|
|
265
|
+
let pid = config.panePid;
|
|
266
|
+
let startTime = await getProcessStartTime(pid);
|
|
289
267
|
if (!startTime) return;
|
|
290
|
-
let
|
|
291
|
-
let existing = (await listProcessRecords()).find(x => x.launchId === launchId && x.folder === config.folder);
|
|
268
|
+
let existing = await readProcessRecord(config.folder, pid, startTime);
|
|
292
269
|
if (existing) {
|
|
293
270
|
// A takeover renames the session, so the record follows the name it now runs under
|
|
294
271
|
if (existing.screenName === config.screenName) return;
|
|
295
272
|
await writeProcessRecord({ ...existing, screenName: config.screenName });
|
|
296
273
|
return;
|
|
297
274
|
}
|
|
298
|
-
console.log(`Adopting already-running process ${
|
|
275
|
+
console.log(`Adopting already-running process ${pid} on screen ${config.screenName}, which has no record yet`);
|
|
299
276
|
await writeProcessRecord({
|
|
300
277
|
...config.record,
|
|
301
|
-
|
|
278
|
+
pid,
|
|
279
|
+
startTime,
|
|
302
280
|
folder: config.folder,
|
|
303
281
|
screenName: config.screenName,
|
|
304
|
-
pid: parseInt(config.panePid) || undefined,
|
|
305
|
-
startTime,
|
|
306
282
|
});
|
|
307
283
|
// Its output was going to whatever the previous incarnation pointed at (or nowhere), so point it at its own log from here on
|
|
308
|
-
await setupPipePane({ screenName: config.screenName, folder: config.folder,
|
|
284
|
+
await setupPipePane({ screenName: config.screenName, folder: config.folder, pid, startTime });
|
|
309
285
|
}
|
|
310
286
|
|
|
311
|
-
// Points the screen's pipe-pane at this
|
|
312
|
-
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 }) {
|
|
313
289
|
let prefix = getTmuxPrefix();
|
|
314
|
-
let logPath = getProcessLogPath(config.folder, config.
|
|
315
|
-
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);
|
|
316
292
|
await fs.promises.mkdir(path.dirname(logPath), { recursive: true });
|
|
317
293
|
await fs.promises.writeFile(pipeScript, getLogPipeScript(logPath));
|
|
318
294
|
await runPromise(`chmod +x ${pipeScript}`);
|
|
@@ -320,42 +296,69 @@ async function setupPipePane(config: { screenName: string; folder: string; launc
|
|
|
320
296
|
}
|
|
321
297
|
|
|
322
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. */
|
|
323
|
-
export async function takeoverScreen(config: {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
+
return await withScreenLock(config.canonicalScreenName, () => takeoverScreenLocked(config));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The takeover itself. Callers already holding this screen's lock use it directly - taking the lock again from inside it would wait on themselves. */
|
|
309
|
+
export async function takeoverScreenLocked(config: {
|
|
310
|
+
canonicalScreenName: string;
|
|
311
|
+
folder: string;
|
|
312
|
+
replacedNodeId?: string;
|
|
313
|
+
}): Promise<boolean> {
|
|
314
|
+
let { canonicalScreenName, folder, replacedNodeId } = config;
|
|
315
|
+
let futureScreenName = getFutureScreenName(canonicalScreenName);
|
|
316
|
+
let futurePid = await getScreenPanePid(futureScreenName);
|
|
317
|
+
if (!futurePid) return false;
|
|
318
|
+
let prefix = getTmuxPrefix();
|
|
319
|
+
|
|
320
|
+
let outgoingPid = await getScreenPanePid(canonicalScreenName);
|
|
321
|
+
if (outgoingPid) {
|
|
322
|
+
// Renamed out of the way immediately, so the canonical name is free at once and the old process winds down on its own
|
|
323
|
+
let retiringScreenName = getRetiringScreenName(canonicalScreenName, Date.now());
|
|
324
|
+
await runPromise(`${prefix}tmux rename-session -t ${canonicalScreenName} ${retiringScreenName}`);
|
|
325
|
+
await renameProcessScreen({ folder, panePid: outgoingPid, screenName: retiringScreenName });
|
|
326
|
+
if (replacedNodeId) {
|
|
327
|
+
// The file in the folder belongs to the replacement now, so only the registration goes
|
|
328
|
+
void removeServiceNode({ folder, nodeId: replacedNodeId, ownsFile: false });
|
|
348
329
|
}
|
|
349
|
-
|
|
350
|
-
|
|
330
|
+
retiringScreens.add(retiringScreenName);
|
|
331
|
+
void killScreen({ screenName: retiringScreenName }).finally(() => {
|
|
332
|
+
retiringScreens.delete(retiringScreenName);
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
console.log(green(`Renaming ${futureScreenName} to ${canonicalScreenName} (takeover complete)`));
|
|
336
|
+
await runPromise(`${prefix}tmux rename-session -t ${futureScreenName} ${canonicalScreenName}`);
|
|
337
|
+
await renameProcessScreen({ folder, panePid: futurePid, screenName: canonicalScreenName });
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Points a process's record at the session name it now runs under. The pid is the key, so this reads exactly one file. */
|
|
342
|
+
async function renameProcessScreen(config: { folder: string; panePid: string; screenName: string }): Promise<void> {
|
|
343
|
+
let startTime = await getProcessStartTime(config.panePid);
|
|
344
|
+
if (!startTime) return;
|
|
345
|
+
let record = await readProcessRecord(config.folder, config.panePid, startTime);
|
|
346
|
+
if (!record || record.screenName === config.screenName) return;
|
|
347
|
+
await writeProcessRecord({ ...record, screenName: config.screenName });
|
|
351
348
|
}
|
|
352
349
|
|
|
353
350
|
// canonical screen name -> the takeover we already have scheduled for it
|
|
354
351
|
const scheduledTakeovers = new Map<string, { time: number; cancel: () => void }>();
|
|
355
352
|
|
|
356
353
|
/** 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. */
|
|
357
|
-
export function scheduleTakeover(config: {
|
|
358
|
-
|
|
354
|
+
export function scheduleTakeover(config: {
|
|
355
|
+
canonicalScreenName: string;
|
|
356
|
+
folder: string;
|
|
357
|
+
killTime: number;
|
|
358
|
+
/** The node the version being replaced registered as, read before the replacement overwrote the folder's file. */
|
|
359
|
+
replacedNodeId?: string;
|
|
360
|
+
}): void {
|
|
361
|
+
let { canonicalScreenName, folder, killTime, replacedNodeId } = config;
|
|
359
362
|
let existing = scheduledTakeovers.get(canonicalScreenName);
|
|
360
363
|
if (existing?.time === killTime) return;
|
|
361
364
|
existing?.cancel();
|
|
@@ -364,12 +367,25 @@ export function scheduleTakeover(config: { canonicalScreenName: string; folder:
|
|
|
364
367
|
time: killTime,
|
|
365
368
|
callback: () => {
|
|
366
369
|
scheduledTakeovers.delete(canonicalScreenName);
|
|
367
|
-
void takeoverScreen({ canonicalScreenName, folder });
|
|
370
|
+
void takeoverScreen({ canonicalScreenName, folder, replacedNodeId });
|
|
368
371
|
},
|
|
369
372
|
});
|
|
370
373
|
scheduledTakeovers.set(canonicalScreenName, { time: killTime, cancel });
|
|
371
374
|
}
|
|
372
375
|
|
|
376
|
+
// 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.
|
|
377
|
+
const retiringScreens = new Set<string>();
|
|
378
|
+
export function isRetiring(screenName: string): boolean {
|
|
379
|
+
return retiringScreens.has(screenName);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Ends a session at once, with no notice - for a screen that should not exist at all, so there is nothing to wind down gracefully. Finding one is always a surprise, so the reason it was not supposed to be there is logged. */
|
|
383
|
+
export async function killScreenNow(screenName: string, reason: string): Promise<void> {
|
|
384
|
+
if (!await getScreenPanePid(screenName)) return;
|
|
385
|
+
console.log(red(`Unexpected screen ${screenName}: ${reason}. Killing it immediately.`));
|
|
386
|
+
await runPromise(`${getTmuxPrefix()}tmux kill-session -t ${screenName}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
373
389
|
// 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.
|
|
374
390
|
export const killScreen = measureWrap(async function killScreen(config: {
|
|
375
391
|
screenName: string;
|
|
@@ -385,10 +401,11 @@ export const killScreen = measureWrap(async function killScreen(config: {
|
|
|
385
401
|
/** 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. */
|
|
386
402
|
export async function streamProcessOutput(config: {
|
|
387
403
|
folder: string;
|
|
388
|
-
|
|
404
|
+
pid: string;
|
|
405
|
+
startTime: number;
|
|
389
406
|
onData: (data: string) => Promise<void>;
|
|
390
407
|
}) {
|
|
391
|
-
let logPath = getProcessLogPath(config.folder, config.
|
|
408
|
+
let logPath = getProcessLogPath(config.folder, config.pid, config.startTime);
|
|
392
409
|
let serialOnData = runInSerial(config.onData);
|
|
393
410
|
let stopped = false;
|
|
394
411
|
let childProcess: ChildProcess | undefined;
|
|
@@ -407,14 +424,14 @@ export async function streamProcessOutput(config: {
|
|
|
407
424
|
const onDataWrapped = async (data: string) => {
|
|
408
425
|
pendingDataCalls++;
|
|
409
426
|
if (pendingDataCalls > MAX_PENDING_CALLS) {
|
|
410
|
-
console.error(`Too many queued onData calls for ${config.
|
|
427
|
+
console.error(`Too many queued onData calls for ${config.pid}, stopping stream.`);
|
|
411
428
|
await stop();
|
|
412
429
|
return;
|
|
413
430
|
}
|
|
414
431
|
try {
|
|
415
432
|
await serialOnData(data);
|
|
416
433
|
} catch (e: any) {
|
|
417
|
-
console.log(`Callback for stream output ${config.
|
|
434
|
+
console.log(`Callback for stream output ${config.pid} failed. It probably just disconnected, almost certainly not an error: ${e.message}`);
|
|
418
435
|
await stop();
|
|
419
436
|
} finally {
|
|
420
437
|
pendingDataCalls--;
|
|
@@ -422,7 +439,7 @@ export async function streamProcessOutput(config: {
|
|
|
422
439
|
};
|
|
423
440
|
|
|
424
441
|
try {
|
|
425
|
-
let tailScript = getTailScriptPath(config.folder, config.
|
|
442
|
+
let tailScript = getTailScriptPath(config.folder, config.pid, config.startTime);
|
|
426
443
|
await fs.promises.writeFile(tailScript, getLogTailScript(logPath));
|
|
427
444
|
await runPromise(`chmod +x ${tailScript}`);
|
|
428
445
|
|