querysub 0.572.0 → 0.574.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -26,7 +26,7 @@ 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,
|
|
29
|
+
import { getScreenName, getRetiringKillTime, retireScreen, retireCurrentScreen, getTmuxPrefix, getScreenState, isScreenRunningProcess, getProcessStartTime, getScreenPanePid, ensureProcessRecord, runScreenCommand, killScreen, readServiceNodeId, removeServiceNode, killScreenNow, withScreenLock } from "./processManager";
|
|
30
30
|
import { ProcessRecord, syncProcessRecords, listProcessRecords } from "./processLogs";
|
|
31
31
|
|
|
32
32
|
|
|
@@ -221,6 +221,8 @@ let launchesPerService = new Map<string, number>();
|
|
|
221
221
|
let lastLaunchedTimePerService = new Map<string, number>();
|
|
222
222
|
|
|
223
223
|
const LAUNCH_VERIFY_DELAY = 2000;
|
|
224
|
+
// How far a re-read start time may drift from the recorded one and still be the same process. Well under any interval a pid could be reused in, and wide enough for sources that only resolve to the second.
|
|
225
|
+
const START_TIME_TOLERANCE = timeInSecond * 5;
|
|
224
226
|
|
|
225
227
|
/** 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
228
|
function getMachineInstances(configs: ServiceConfig[], machineId: string): { config: ServiceConfig; index: number }[] {
|
|
@@ -254,7 +256,7 @@ function getMachineInstances(configs: ServiceConfig[], machineId: string): { con
|
|
|
254
256
|
return instances;
|
|
255
257
|
}
|
|
256
258
|
|
|
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
|
|
259
|
+
/** 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 screen it is using, so anything nobody claimed can be killed. Everything about the screen happens under its lock, so two passes can never interleave. */
|
|
258
260
|
async function ensureScreen(config: {
|
|
259
261
|
config: ServiceConfig;
|
|
260
262
|
index: number;
|
|
@@ -265,22 +267,16 @@ async function ensureScreen(config: {
|
|
|
265
267
|
let serviceId = config.config.serviceId;
|
|
266
268
|
let serviceConfig = config.config;
|
|
267
269
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
//
|
|
272
|
-
let
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
let
|
|
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 + "/";
|
|
270
|
+
// A version being replaced is renamed aside and given this long before it is taken down, which is the only thing an overlap is
|
|
271
|
+
let killTime = Date.now() + (serviceConfig.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME);
|
|
272
|
+
|
|
273
|
+
// The screen name means one thing: the version that should be running. There is no second name to reason about - anything it replaced is already renamed aside and carries its own deadline.
|
|
274
|
+
let parameters: ServiceParameters | undefined = getLiveServiceParameters(serviceConfig);
|
|
275
|
+
if (!parameters.deploy) parameters = undefined;
|
|
276
|
+
let screenName = getScreenName({ serviceKey: (parameters || serviceConfig.parameters).key, index });
|
|
277
|
+
let folder = os.homedir() + "/" + SERVICE_FOLDER + screenName + "/";
|
|
282
278
|
// Returned even when the work below throws, so a transient failure never makes the sweep kill a screen we still want
|
|
283
|
-
let usedScreenNames = [
|
|
279
|
+
let usedScreenNames = [screenName];
|
|
284
280
|
|
|
285
281
|
let launchCount = launchesPerService.get(serviceId) || 0;
|
|
286
282
|
let lastLaunchedTime = lastLaunchedTimePerService.get(serviceId) || 0;
|
|
@@ -292,108 +288,88 @@ async function ensureScreen(config: {
|
|
|
292
288
|
};
|
|
293
289
|
|
|
294
290
|
try {
|
|
295
|
-
await watchSlowPromise(`ensureScreen|${
|
|
291
|
+
await watchSlowPromise(`ensureScreen|${screenName}`, withScreenLock(screenName, async () => {
|
|
296
292
|
/* Three facts, each moving ? -> known -> correct as we go:
|
|
297
|
-
EXISTS -
|
|
298
|
-
VERSION - which parameters
|
|
299
|
-
RUNNING - whether a process is actually alive in
|
|
293
|
+
EXISTS - whether the screen is there
|
|
294
|
+
VERSION - which parameters it was started with
|
|
295
|
+
RUNNING - whether a process is actually alive in it
|
|
300
296
|
Every step below says where those three stand, so it is always clear what we are still assuming.
|
|
301
297
|
START: EXISTS ? VERSION ? RUNNING ?
|
|
302
298
|
*/
|
|
303
|
-
let parameters = incoming || current;
|
|
304
|
-
let screenName = incoming && futureScreenName || canonicalScreenName;
|
|
305
|
-
let takeoverAt = incoming && killTime || undefined;
|
|
306
299
|
|
|
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
300
|
// 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 (!
|
|
310
|
-
await killScreenNow(
|
|
311
|
-
|
|
312
|
-
if (!current) {
|
|
313
|
-
await killScreenNow(canonicalScreenName, "nothing is supposed to be serving this name yet");
|
|
301
|
+
if (!parameters) {
|
|
302
|
+
await killScreenNow(screenName, "this instance deploys nothing here");
|
|
303
|
+
return;
|
|
314
304
|
}
|
|
315
|
-
if (!current && !incoming) return;
|
|
316
305
|
|
|
317
|
-
//
|
|
318
|
-
// AFTER: VERSION known
|
|
306
|
+
// The folder holds the version the screen was last started from
|
|
307
|
+
// AFTER: VERSION known
|
|
319
308
|
let parameterPath = folder + "parameters.json";
|
|
320
309
|
let prevParameters = "";
|
|
321
310
|
if (await fsExistsAsync(parameterPath)) {
|
|
322
311
|
prevParameters = await fs.promises.readFile(parameterPath, "utf8");
|
|
323
312
|
}
|
|
324
313
|
|
|
325
|
-
// AFTER: RUNNING known
|
|
326
|
-
let
|
|
327
|
-
let
|
|
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 };
|
|
314
|
+
// AFTER: RUNNING known. 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.
|
|
315
|
+
let panePid = await getScreenPanePid(screenName);
|
|
316
|
+
let isUp = !!panePid && await isScreenRunningProcess(panePid);
|
|
358
317
|
|
|
359
318
|
let newParametersString = JSON.stringify(parameters);
|
|
319
|
+
let record = { serviceId, serviceKey: parameters.key, index, machineId, parameters };
|
|
360
320
|
|
|
361
321
|
let nodePathId = folder + SERVICE_NODE_FILE_NAME;
|
|
362
322
|
if (await fsExistsAsync(nodePathId)) {
|
|
363
323
|
machineInfo.services[serviceId].nodeId = await fs.promises.readFile(nodePathId, "utf8");
|
|
364
324
|
}
|
|
365
325
|
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
|
|
326
|
+
// SMOOTH PATH: what is running is not what should be, so it is renamed aside with its own deadline and keeps serving out its overlap there. The name is free from here, which is what makes a replacement and a first start the same thing.
|
|
327
|
+
if (isUp && panePid && !sameRestartParameters(prevParameters, parameters)) {
|
|
328
|
+
console.log(green(`Replacing what is running as ${magenta(screenName)}: ${newParametersString}`));
|
|
329
|
+
// Recorded against the parameters it was actually started with, not the ones replacing it, so its history says what it really ran
|
|
330
|
+
let outgoingParameters: ServiceParameters | undefined;
|
|
331
|
+
try {
|
|
332
|
+
outgoingParameters = JSON.parse(prevParameters) as ServiceParameters;
|
|
333
|
+
} catch {
|
|
334
|
+
// Never started by us, or the file is unreadable - there is nothing to record it against
|
|
335
|
+
}
|
|
336
|
+
if (outgoingParameters) {
|
|
337
|
+
await ensureProcessRecord({
|
|
338
|
+
screenName,
|
|
339
|
+
folder,
|
|
340
|
+
panePid,
|
|
341
|
+
record: { serviceId, serviceKey: outgoingParameters.key, index, machineId, parameters: outgoingParameters },
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
let outgoingNodeId = await readServiceNodeId(folder);
|
|
345
|
+
if (outgoingNodeId) {
|
|
346
|
+
void notifyNodeShutdown(screenName, outgoingNodeId, killTime);
|
|
347
|
+
}
|
|
348
|
+
await retireCurrentScreen({ canonicalScreenName: screenName, folder, killTime, nodeId: outgoingNodeId });
|
|
349
|
+
isUp = false;
|
|
350
|
+
panePid = undefined;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// A screen with nothing alive in it is just debris in the way of starting one
|
|
354
|
+
if (!isUp && panePid) {
|
|
355
|
+
await killScreenNow(screenName, "the screen is there but nothing is running in it");
|
|
356
|
+
panePid = undefined;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// AFTER: EXISTS correct VERSION correct RUNNING correct - nothing to start
|
|
360
|
+
if (isUp && panePid) {
|
|
369
361
|
if (prevParameters !== newParametersString) {
|
|
370
362
|
await fs.promises.writeFile(parameterPath, newParametersString);
|
|
371
363
|
}
|
|
372
364
|
// 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
365
|
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
|
-
}
|
|
366
|
+
await syncParametersTimelineFiles(folder, [{ pid: parseInt(panePid) || undefined, aliveWindow: [parameters.releaseTime || 0, ALIVE_WINDOW_FOREVER], parameters }]);
|
|
385
367
|
console.log(green(`Verified ${magenta(screenName)} is running`));
|
|
386
368
|
return;
|
|
387
369
|
}
|
|
388
370
|
|
|
389
|
-
//
|
|
390
|
-
|
|
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
|
-
}
|
|
371
|
+
// The name is free, so a first start and a replacement are the same from here
|
|
372
|
+
console.log(red(`Nothing is running as ${screenName}, starting it: ${newParametersString}`));
|
|
397
373
|
|
|
398
374
|
// 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
375
|
let gitFolder = folder + "git/";
|
|
@@ -417,42 +393,24 @@ async function ensureScreen(config: {
|
|
|
417
393
|
void recordLaunch({
|
|
418
394
|
serviceId,
|
|
419
395
|
serviceKey: parameters.key,
|
|
420
|
-
screenName
|
|
396
|
+
screenName,
|
|
421
397
|
machineId,
|
|
422
|
-
reason:
|
|
398
|
+
reason: prevParameters && "update" || "crashed",
|
|
423
399
|
time: Date.now(),
|
|
424
400
|
});
|
|
425
401
|
|
|
426
|
-
|
|
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({
|
|
432
|
-
screenName: launchScreenName,
|
|
433
|
-
folder,
|
|
434
|
-
command: parameters.command,
|
|
435
|
-
record,
|
|
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
|
-
}
|
|
402
|
+
let startedPid = await runScreenCommand({ screenName, folder, command: parameters.command, record });
|
|
441
403
|
|
|
442
404
|
await delay(LAUNCH_VERIFY_DELAY);
|
|
443
|
-
let
|
|
444
|
-
if (!
|
|
405
|
+
let startedPanePid = await getScreenPanePid(screenName);
|
|
406
|
+
if (!startedPanePid || !await isScreenRunningProcess(startedPanePid)) {
|
|
445
407
|
let prefix = getTmuxPrefix();
|
|
446
|
-
let logs = await runPromise(`${prefix}tmux capture-pane -t ${
|
|
447
|
-
throw new Error(`${logs}\n\nService ${magenta(
|
|
448
|
-
}
|
|
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 });
|
|
408
|
+
let logs = await runPromise(`${prefix}tmux capture-pane -t ${screenName} -p`);
|
|
409
|
+
throw new Error(`${logs}\n\nService ${magenta(screenName)} is not running after starting. Trying again in ${formatTime(MACHINE_RESYNC_INTERVAL)}, or on next change. Last logs above.`);
|
|
454
410
|
}
|
|
455
|
-
|
|
411
|
+
// AFTER: EXISTS correct VERSION correct RUNNING correct
|
|
412
|
+
console.log(green(`${magenta(screenName)} is verified to be running (pid ${startedPid})`));
|
|
413
|
+
await syncParametersTimelineFiles(folder, [{ pid: parseInt(startedPanePid) || undefined, aliveWindow: [parameters.releaseTime || 0, ALIVE_WINDOW_FOREVER], parameters }]);
|
|
456
414
|
|
|
457
415
|
launchCount++;
|
|
458
416
|
lastLaunchedTime = Date.now();
|
|
@@ -461,7 +419,7 @@ async function ensureScreen(config: {
|
|
|
461
419
|
// Counted on failure too, as a service that keeps crashing is only visible as a launch count climbing faster than everyone else's
|
|
462
420
|
launchCount++;
|
|
463
421
|
lastLaunchedTime = Date.now();
|
|
464
|
-
console.error(`Error resyncing ${magenta(
|
|
422
|
+
console.error(`Error resyncing ${magenta(screenName)} (${serviceId}): ${e.stack}`);
|
|
465
423
|
machineInfo.services[serviceId].errorFromLastRun = e.stack;
|
|
466
424
|
}
|
|
467
425
|
machineInfo.services[serviceId].totalTimesLaunched = launchCount;
|
|
@@ -497,7 +455,8 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
|
|
|
497
455
|
await syncProcessRecords({
|
|
498
456
|
isAlive: async record => {
|
|
499
457
|
if (!running.has(record.pid)) return false;
|
|
500
|
-
|
|
458
|
+
// Compared with a tolerance rather than exactly: the sources we read a start time from only resolve to the second, and they do not all round the same way, so an exact match would call a live process dead
|
|
459
|
+
return Math.abs(await getProcessStartTime(record.pid) - record.startTime) <= START_TIME_TOLERANCE;
|
|
501
460
|
},
|
|
502
461
|
now: Date.now(),
|
|
503
462
|
});
|
|
@@ -513,8 +472,11 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
|
|
|
513
472
|
// Anything no instance claimed is not supposed to exist
|
|
514
473
|
for (let { screenName } of screenState) {
|
|
515
474
|
if (usedScreenNames.has(screenName)) continue;
|
|
516
|
-
//
|
|
517
|
-
if (
|
|
475
|
+
// A retiring screen is not unclaimed - it is a version that was replaced, and its name says when it is due to go. Cached per name, so seeing it on every resync only ever starts one, and a restart picks it up again from the name alone.
|
|
476
|
+
if (getRetiringKillTime(screenName) !== undefined) {
|
|
477
|
+
void retireScreen(screenName);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
518
480
|
// Detached, so a screen that takes its full grace period to go down doesn't hold up the end of the resync
|
|
519
481
|
void (async () => {
|
|
520
482
|
// Nothing is replacing this one, so its registration and the file it wrote both go
|
|
@@ -99,6 +99,11 @@ export async function readProcessRecord(folder: string, pid: string, startTime:
|
|
|
99
99
|
return JSON.parse(await fs.promises.readFile(file, "utf8")) as ProcessRecord;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/** The live record for a pid in this folder. The pid is what identifies a process, so a start time that reads back a second off - the sources only resolve to the second - must not make us miss the record and write a duplicate. */
|
|
103
|
+
export async function findProcessRecordByPid(folder: string, pid: string): Promise<ProcessRecord | undefined> {
|
|
104
|
+
return (await readFolderRecords(folder)).find(x => x.pid === pid && x.deadTime === undefined);
|
|
105
|
+
}
|
|
106
|
+
|
|
102
107
|
export async function writeProcessRecord(record: ProcessRecord): Promise<void> {
|
|
103
108
|
await fs.promises.mkdir(path.join(record.folder, PROCESS_FOLDER), { recursive: true });
|
|
104
109
|
await fs.promises.writeFile(getRecordPath(record.folder, record.pid, record.startTime), JSON.stringify(record));
|
|
@@ -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, getLogPipeScript, getLogTailScript, getProcessLogPath, getPipeScriptPath, getTailScriptPath,
|
|
16
|
+
import { ProcessRecord, getLogPipeScript, getLogTailScript, getProcessLogPath, getPipeScriptPath, getTailScriptPath, findProcessRecordByPid, 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,17 +25,35 @@ 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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return canonicalScreenName.slice(0, -SCREEN_SUFFIX.length) + RETIRING_MARKER + at + SCREEN_SUFFIX;
|
|
28
|
+
const RETIRING_MARKER = "-retiring-";
|
|
29
|
+
/** A version being replaced is renamed to this, which frees the canonical name for its replacement at once. The name carries WHEN it is to be killed, so nothing has to remember: any pass that sees the screen can read its own deadline off it, and a restart loses nothing. */
|
|
30
|
+
export function getRetiringScreenName(canonicalScreenName: string, killTime: number): string {
|
|
31
|
+
return canonicalScreenName.slice(0, -SCREEN_SUFFIX.length) + RETIRING_MARKER + killTime + SCREEN_SUFFIX;
|
|
33
32
|
}
|
|
34
|
-
|
|
35
|
-
export function
|
|
36
|
-
|
|
33
|
+
/** When a retiring screen is due to be killed, or undefined when it is not a retiring screen at all. */
|
|
34
|
+
export function getRetiringKillTime(screenName: string): number | undefined {
|
|
35
|
+
let marker = screenName.lastIndexOf(RETIRING_MARKER);
|
|
36
|
+
if (marker === -1) return undefined;
|
|
37
|
+
return parseInt(screenName.slice(marker + RETIRING_MARKER.length, -SCREEN_SUFFIX.length)) || undefined;
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
/** Takes a retiring screen down at the time its name says. Cached per screen, so seeing the same screen on every resync only ever starts one of these, and it drops itself from the cache once the screen is gone. */
|
|
41
|
+
export const retireScreen = cache(async (screenName: string): Promise<void> => {
|
|
42
|
+
try {
|
|
43
|
+
let killTime = getRetiringKillTime(screenName);
|
|
44
|
+
if (killTime === undefined) return;
|
|
45
|
+
let dueAt = killTime;
|
|
46
|
+
if (dueAt > Date.now()) {
|
|
47
|
+
console.log(green(`${screenName} is retiring, and is due to be killed at ${new Date(dueAt).toISOString()}`));
|
|
48
|
+
await new Promise<void>(resolve => setPreciseTimeout({ time: dueAt, callback: () => resolve() }));
|
|
49
|
+
}
|
|
50
|
+
await killScreen({ screenName });
|
|
51
|
+
} finally {
|
|
52
|
+
// Dropped either way: a failure should be retried by the next resync rather than remembered as done
|
|
53
|
+
retireScreen.clear(screenName);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
39
57
|
|
|
40
58
|
// 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
59
|
const getScreenLock = cache((canonicalScreenName: string) => runInSerial(async (run: () => Promise<unknown>) => await run()));
|
|
@@ -163,11 +181,23 @@ const getAllParentPids = measureWrap(async function getAllParentPids(): Promise<
|
|
|
163
181
|
let obj = textTableLineToObj(table) as { PID: string; PPID: string }[];
|
|
164
182
|
return new Set(obj.map(x => x.PPID));
|
|
165
183
|
});
|
|
166
|
-
/** The start time the OS reports for a pid, as an epoch time in milliseconds like every other time we store. Asked of the machine rather than remembered, so it stays true across our restarts and catches a reused pid. Returns 0 when the process is gone. */
|
|
184
|
+
/** The start time the OS reports for a pid, as an epoch time in milliseconds like every other time we store. Asked of the machine rather than remembered, so it stays true across our restarts and catches a reused pid. Returns 0 when the process is gone, or when neither source can answer. */
|
|
167
185
|
export const getProcessStartTime = measureWrap(async function getProcessStartTime(pid: string): Promise<number> {
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
186
|
+
// /proc/<pid> is created when the process is, so its ctime IS the start time - no date parsing, and nothing to get wrong about locales or ps output formats
|
|
187
|
+
try {
|
|
188
|
+
let started = await runPromise(`stat -c %Y /proc/${pid}`, { quiet: true });
|
|
189
|
+
let seconds = parseInt(started.trim()) || 0;
|
|
190
|
+
if (seconds) return seconds * 1000;
|
|
191
|
+
} catch {
|
|
192
|
+
// No procfs (or the process is gone), so fall through to ps
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
// Second-accurate, which is plenty to tell two processes on the same pid apart
|
|
196
|
+
let started = await runPromise(`date -d "$(ps -o lstart= -p ${pid})" +%s`, { quiet: true });
|
|
197
|
+
return (parseInt(started.trim()) || 0) * 1000;
|
|
198
|
+
} catch {
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
171
201
|
});
|
|
172
202
|
|
|
173
203
|
export const isScreenRunningProcess = measureWrap(async function isScreenRunningProcess(pid: string): Promise<boolean> {
|
|
@@ -263,9 +293,9 @@ export async function ensureProcessRecord(config: {
|
|
|
263
293
|
record: Omit<ProcessRecord, "pid" | "startTime" | "folder" | "screenName">;
|
|
264
294
|
}): Promise<void> {
|
|
265
295
|
let pid = config.panePid;
|
|
296
|
+
// A start time we could not read is still recorded: a process with no record at all is invisible everywhere, which is worse than one whose identity is only its pid
|
|
266
297
|
let startTime = await getProcessStartTime(pid);
|
|
267
|
-
|
|
268
|
-
let existing = await readProcessRecord(config.folder, pid, startTime);
|
|
298
|
+
let existing = await findProcessRecordByPid(config.folder, pid);
|
|
269
299
|
if (existing) {
|
|
270
300
|
// A takeover renames the session, so the record follows the name it now runs under
|
|
271
301
|
if (existing.screenName === config.screenName) return;
|
|
@@ -295,88 +325,28 @@ async function setupPipePane(config: { screenName: string; folder: string; pid:
|
|
|
295
325
|
await runPromise(`${prefix}tmux pipe-pane -t ${config.screenName} 'bash ${pipeScript}'`);
|
|
296
326
|
}
|
|
297
327
|
|
|
298
|
-
/**
|
|
299
|
-
export async function
|
|
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: {
|
|
328
|
+
/** Moves whatever is running under this name aside so its replacement can have the name at once, and gives it until killTime before it is taken down. Does nothing when the name is free. */
|
|
329
|
+
export async function retireCurrentScreen(config: {
|
|
310
330
|
canonicalScreenName: string;
|
|
311
331
|
folder: string;
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
let
|
|
317
|
-
if (!futurePid) return false;
|
|
318
|
-
let prefix = getTmuxPrefix();
|
|
319
|
-
|
|
332
|
+
killTime: number;
|
|
333
|
+
/** The node it registered as, read before its replacement overwrites the folder's file. */
|
|
334
|
+
nodeId?: string;
|
|
335
|
+
}): Promise<void> {
|
|
336
|
+
let { canonicalScreenName, folder, killTime, nodeId } = config;
|
|
320
337
|
let outgoingPid = await getScreenPanePid(canonicalScreenName);
|
|
321
|
-
if (outgoingPid)
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
void
|
|
332
|
-
retiringScreens.delete(retiringScreenName);
|
|
333
|
-
});
|
|
338
|
+
if (!outgoingPid) return;
|
|
339
|
+
let retiringScreenName = getRetiringScreenName(canonicalScreenName, killTime);
|
|
340
|
+
console.log(green(`Retiring ${canonicalScreenName} as ${retiringScreenName}`));
|
|
341
|
+
await runPromise(`${getTmuxPrefix()}tmux rename-session -t ${canonicalScreenName} ${retiringScreenName}`);
|
|
342
|
+
let record = await findProcessRecordByPid(folder, outgoingPid);
|
|
343
|
+
if (record) {
|
|
344
|
+
await writeProcessRecord({ ...record, screenName: retiringScreenName });
|
|
345
|
+
}
|
|
346
|
+
if (nodeId) {
|
|
347
|
+
// The file in the folder belongs to its replacement now, so only the registration goes
|
|
348
|
+
void removeServiceNode({ folder, nodeId, ownsFile: false });
|
|
334
349
|
}
|
|
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 });
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// canonical screen name -> the takeover we already have scheduled for it
|
|
351
|
-
const scheduledTakeovers = new Map<string, { time: number; cancel: () => void }>();
|
|
352
|
-
|
|
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. */
|
|
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;
|
|
362
|
-
let existing = scheduledTakeovers.get(canonicalScreenName);
|
|
363
|
-
if (existing?.time === killTime) return;
|
|
364
|
-
existing?.cancel();
|
|
365
|
-
console.log(green(`Scheduling the takeover of ${canonicalScreenName} for ${new Date(killTime).toISOString()}`));
|
|
366
|
-
let cancel = setPreciseTimeout({
|
|
367
|
-
time: killTime,
|
|
368
|
-
callback: () => {
|
|
369
|
-
scheduledTakeovers.delete(canonicalScreenName);
|
|
370
|
-
void takeoverScreen({ canonicalScreenName, folder, replacedNodeId });
|
|
371
|
-
},
|
|
372
|
-
});
|
|
373
|
-
scheduledTakeovers.set(canonicalScreenName, { time: killTime, cancel });
|
|
374
|
-
}
|
|
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
350
|
}
|
|
381
351
|
|
|
382
352
|
/** 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. */
|