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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.572.0",
3
+ "version": "0.574.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -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, getFutureScreenName, isRetiring, getTmuxPrefix, getScreenState, isScreenRunningProcess, getProcessStartTime, getScreenPanePid, ensureProcessRecord, runScreenCommand, killScreen, readServiceNodeId, removeServiceNode, takeoverScreenLocked, scheduleTakeover, killScreenNow, withScreenLock } from "./processManager";
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 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. */
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
- 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 + "/";
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 = [canonicalScreenName, futureScreenName];
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|${canonicalScreenName}`, withScreenLock(canonicalScreenName, async () => {
291
+ await watchSlowPromise(`ensureScreen|${screenName}`, withScreenLock(screenName, async () => {
296
292
  /* 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
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 (!incoming) {
310
- await killScreenNow(futureScreenName, "there is no release in flight, so nothing should be waiting to take over");
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
- // 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.
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 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 };
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
- // 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)) {
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
- // 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
- }
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: canonicalScreenName,
396
+ screenName,
421
397
  machineId,
422
- reason: isUp && "update" || "crashed",
398
+ reason: prevParameters && "update" || "crashed",
423
399
  time: Date.now(),
424
400
  });
425
401
 
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({
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 launchedPanePid = await getScreenPanePid(launchScreenName);
444
- if (!launchedPanePid || !await isScreenRunningProcess(launchedPanePid)) {
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 ${launchScreenName} -p`);
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.`);
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
- await syncParametersTimelineFiles(folder, [{ pid: parseInt(launchedPanePid) || undefined, aliveWindow, parameters }]);
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(canonicalScreenName)} (${serviceId}): ${e.stack}`);
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
- return await getProcessStartTime(record.pid) === record.startTime;
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
- // Already being taken down. One left over from a restart is not, so it still gets cleaned up here.
517
- if (isRetiring(screenName)) continue;
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, readProcessRecord, writeProcessRecord } from "./processLogs";
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
- /** Marks a session that has already been handed off and is winding down on its own timer. */
29
- export const RETIRING_MARKER = "-retiring-";
30
- /** The name an outgoing screen is moved to so the new process can take the canonical name at once, instead of anything waiting for the old process to wind down. Keeps the suffix, so it is still a screen we know about. */
31
- export function getRetiringScreenName(canonicalScreenName: string, at: number): string {
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
- // The new version's screen during a release, in the SAME folder as the canonical screen: created (just echoing when it will start) shortly before releaseTime, started at releaseTime, and renamed to the canonical screen name once the old screen is killed at releaseTime + overlapTime.
35
- export function getFutureScreenName(canonicalScreenName: string): string {
36
- return canonicalScreenName.slice(0, -SCREEN_SUFFIX.length) + "-future" + SCREEN_SUFFIX;
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
- // ps only resolves to the second, so this is second-accurate - which is plenty to tell two processes on the same pid apart
169
- let started = await runPromise(`date -d "$(ps -o lstart= -p ${pid})" +%s`, { quiet: true });
170
- return (parseInt(started.trim()) || 0) * 1000;
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
- if (!startTime) return;
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
- /** 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. */
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: {
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
- 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
-
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
- // 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 });
329
- }
330
- retiringScreens.add(retiringScreenName);
331
- void killScreen({ screenName: retiringScreenName }).finally(() => {
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. */