querysub 0.571.0 → 0.573.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.571.0",
3
+ "version": "0.573.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",
@@ -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,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, takeoverScreen, scheduleTakeover, withScreenLock } from "./processManager";
29
+ import { getScreenName, getFutureScreenName, isRetiring, getTmuxPrefix, getScreenState, isScreenRunningProcess, getProcessStartTime, getScreenPanePid, ensureProcessRecord, runScreenCommand, killScreen, readServiceNodeId, removeServiceNode, takeoverScreenLocked, scheduleTakeover, killScreenNow, withScreenLock } from "./processManager";
30
30
  import { ProcessRecord, syncProcessRecords, listProcessRecords } from "./processLogs";
31
31
 
32
32
 
@@ -221,87 +221,68 @@ 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
- /** One tmux session that should exist right now, and the configuration it should be running. The whole resync is derived from this list: converge each of these, kill everything else. */
226
- type DesiredScreen = {
227
- /** The session that should exist. For an incoming version that is the "-future" name, until it takes the canonical name over. */
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();
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. */
228
+ function getMachineInstances(configs: ServiceConfig[], machineId: string): { config: ServiceConfig; index: number }[] {
229
+ let instances: { config: ServiceConfig; index: number }[] = [];
250
230
  for (let config of configs) {
251
- let releaseTime = config.parameters.releaseTime || 0;
252
- // The overlap length always comes from the NEWEST parameters, even though it governs how long the OLD instances outlive the release
253
- let overlapTime = config.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME;
254
- let killTime = releaseTime + overlapTime;
255
- // Only in flight while there is an old version to keep alive, and only until its overlap runs out
256
- let releaseInFlight = !!releaseTime && !!config.oldParameters && now < killTime;
257
-
258
- const add = (parameters: ServiceParameters, extra: Partial<DesiredScreen>) => {
259
- if (!parameters.deploy) return;
260
- let targets = getMachineTargets(parameters).filter(target => target.machineId === machineId);
261
- for (let index = 0; index < targets.length; index++) {
262
- let canonicalScreenName = getScreenName({ serviceKey: parameters.key, index });
263
- desired.push({
264
- screenName: canonicalScreenName,
265
- canonicalScreenName,
266
- folder: root + canonicalScreenName + "/",
267
- serviceId: config.serviceId,
268
- serviceKey: parameters.key,
269
- index,
270
- machineId,
271
- // Each instance runs the command with its own entry's variables resolved, so the parameters written to disk and compared for restarts carry the resolved command
272
- parameters: { ...parameters, command: applyCommandTemplate(parameters.command, targets[index].variables) },
273
- aliveWindow: [parameters.releaseTime || 0, ALIVE_WINDOW_FOREVER],
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), {});
231
+ // A release can add or remove instances, so the count is whichever version asks for the most
232
+ let count = 0;
233
+ for (let parameters of [config.parameters, config.oldParameters]) {
234
+ if (!parameters) continue;
235
+ count = Math.max(count, getMachineTargets(parameters).filter(x => x.machineId === machineId).length);
236
+ }
237
+ for (let index = 0; index < count; index++) {
238
+ const resolve = (parameters: ServiceParameters) => {
239
+ let target = getMachineTargets(parameters).filter(x => x.machineId === machineId)[index];
240
+ return {
241
+ ...parameters,
242
+ command: target && applyCommandTemplate(parameters.command, target.variables) || parameters.command,
243
+ deploy: !!parameters.deploy && !!target,
244
+ };
245
+ };
246
+ instances.push({
247
+ index,
248
+ config: {
249
+ ...config,
250
+ parameters: resolve(config.parameters),
251
+ oldParameters: config.oldParameters && resolve(config.oldParameters),
252
+ },
253
+ });
288
254
  }
289
255
  }
290
- // An incoming version lives under the future name until it takes over
291
- for (let screen of desired) {
292
- if (screen.takeoverAt === undefined) continue;
293
- screen.screenName = getFutureScreenName(screen.canonicalScreenName);
294
- }
295
- return desired;
256
+ return instances;
296
257
  }
297
258
 
298
- /** Puts one screen into the state its configuration describes: the folder synced, the process running what it should be, and the records and timeline saying so. 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 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
260
  async function ensureScreen(config: {
300
- desired: DesiredScreen;
261
+ config: ServiceConfig;
262
+ index: number;
301
263
  machineInfo: MachineInfo;
302
- }): Promise<void> {
303
- let { desired, machineInfo } = config;
304
- let { screenName, canonicalScreenName, folder, parameters, serviceId } = desired;
264
+ }): Promise<string[]> {
265
+ let { machineInfo, index } = config;
266
+ let machineId = machineInfo.machineId;
267
+ let serviceId = config.config.serviceId;
268
+ let serviceConfig = config.config;
269
+
270
+ let releaseTime = serviceConfig.parameters.releaseTime || 0;
271
+ // The overlap length always comes from the NEWEST parameters, even though it governs how long the OLD instances outlive the release
272
+ let killTime = releaseTime + (serviceConfig.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME);
273
+ // Only in flight while there is an old version to keep alive, and only until its overlap runs out
274
+ let releaseInFlight = !!releaseTime && !!serviceConfig.oldParameters && Date.now() < killTime;
275
+
276
+ // What should be running under the canonical name now, and the version waiting to take it over
277
+ let current: ServiceParameters | undefined = releaseInFlight && serviceConfig.oldParameters || getLiveServiceParameters(serviceConfig);
278
+ if (!current?.deploy) current = undefined;
279
+ let incoming: ServiceParameters | undefined = releaseInFlight && Date.now() >= releaseTime && serviceConfig.parameters || undefined;
280
+ if (!incoming?.deploy) incoming = undefined;
281
+ let canonicalScreenName = getScreenName({ serviceKey: (current || incoming || serviceConfig.parameters).key, index });
282
+ let futureScreenName = getFutureScreenName(canonicalScreenName);
283
+ let folder = os.homedir() + "/" + SERVICE_FOLDER + canonicalScreenName + "/";
284
+ // Returned even when the work below throws, so a transient failure never makes the sweep kill a screen we still want
285
+ let usedScreenNames = [canonicalScreenName, futureScreenName];
305
286
 
306
287
  let launchCount = launchesPerService.get(serviceId) || 0;
307
288
  let lastLaunchedTime = lastLaunchedTimePerService.get(serviceId) || 0;
@@ -313,47 +294,70 @@ async function ensureScreen(config: {
313
294
  };
314
295
 
315
296
  try {
316
- await withScreenLock(canonicalScreenName, async () => {
317
- // Asked of tmux here, not handed in: this decides everything from the names it was given
318
- let panePid = await getScreenPanePid(screenName);
319
- let isUp = !!panePid && await isScreenRunningProcess(panePid);
320
-
321
- if (desired.outgoing) {
322
- // Its replacement owns the folder now, so the only thing to do is keep its record straight and let it know when it goes down
323
- if (panePid && isUp) {
324
- await ensureProcessRecord({ screenName, folder, panePid, record: desired });
325
- void (async () => {
326
- let nodeId = await readServiceNodeId(folder);
327
- if (nodeId) {
328
- await notifyNodeShutdown(screenName, nodeId, desired.aliveWindow[1]);
329
- }
330
- })();
331
- }
332
- return;
297
+ await watchSlowPromise(`ensureScreen|${canonicalScreenName}`, withScreenLock(canonicalScreenName, async () => {
298
+ /* Three facts, each moving ? -> known -> correct as we go:
299
+ EXISTS - which of the two screens are there
300
+ VERSION - which parameters they were started with
301
+ RUNNING - whether a process is actually alive in them
302
+ Every step below says where those three stand, so it is always clear what we are still assuming.
303
+ START: EXISTS ? VERSION ? RUNNING ?
304
+ */
305
+ let parameters = incoming || current;
306
+ let screenName = incoming && futureScreenName || canonicalScreenName;
307
+ let takeoverAt = incoming && killTime || undefined;
308
+
309
+ // 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.
310
+ // AFTER: EXISTS correct in the "nothing extra" sense. A screen that SHOULD exist may still be missing, which is RUNNING's problem below.
311
+ if (!incoming) {
312
+ await killScreenNow(futureScreenName, "there is no release in flight, so nothing should be waiting to take over");
333
313
  }
334
-
335
- let gitFolder = folder + "git/";
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
- }
314
+ if (!current) {
315
+ await killScreenNow(canonicalScreenName, "nothing is supposed to be serving this name yet");
350
316
  }
317
+ if (!current && !incoming) return;
351
318
 
319
+ // 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.
320
+ // 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
321
  let parameterPath = folder + "parameters.json";
353
322
  let prevParameters = "";
354
323
  if (await fsExistsAsync(parameterPath)) {
355
324
  prevParameters = await fs.promises.readFile(parameterPath, "utf8");
356
325
  }
326
+
327
+ // 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.
328
+ let canonicalPid = await getScreenPanePid(canonicalScreenName);
329
+ let canonicalUp = !!canonicalPid && await isScreenRunningProcess(canonicalPid);
330
+ let futurePid = incoming && await getScreenPanePid(futureScreenName) || undefined;
331
+ let futureUp = !!futurePid && await isScreenRunningProcess(futurePid);
332
+
333
+ // 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.
334
+ // AFTER (canonical, during a release): EXISTS correct VERSION correct RUNNING correct
335
+ if (incoming && current && !canonicalUp) {
336
+ console.log(red(`Nothing is running as ${canonicalScreenName}, which should be serving until ${new Date(killTime).toISOString()} while its replacement starts`));
337
+ }
338
+ if (incoming && current && canonicalUp && canonicalPid) {
339
+ await ensureProcessRecord({
340
+ screenName: canonicalScreenName,
341
+ folder,
342
+ panePid: canonicalPid,
343
+ record: { serviceId, serviceKey: current.key, index, machineId, parameters: current },
344
+ });
345
+ void (async () => {
346
+ let nodeId = await readServiceNodeId(folder);
347
+ if (nodeId) {
348
+ await notifyNodeShutdown(canonicalScreenName, nodeId, killTime);
349
+ }
350
+ })();
351
+ }
352
+
353
+ // From here on this is about ONE screen - the future one during a release, the canonical one otherwise. Everything else is already correct.
354
+ // STATE: EXISTS correct VERSION known RUNNING known
355
+ if (!parameters) return;
356
+ let panePid = incoming && futurePid || canonicalPid;
357
+ let isUp = incoming && futureUp || canonicalUp;
358
+ let aliveWindow: [number, number] = [parameters.releaseTime || 0, ALIVE_WINDOW_FOREVER];
359
+ let record = { serviceId, serviceKey: parameters.key, index, machineId, parameters };
360
+
357
361
  let newParametersString = JSON.stringify(parameters);
358
362
 
359
363
  let nodePathId = folder + SERVICE_NODE_FILE_NAME;
@@ -361,28 +365,62 @@ async function ensureScreen(config: {
361
365
  machineInfo.services[serviceId].nodeId = await fs.promises.readFile(nodePathId, "utf8");
362
366
  }
363
367
 
368
+ // Already running this exact version, so nothing needs starting - this is the state we are trying to reach
369
+ // AFTER: EXISTS correct VERSION correct RUNNING correct, and the takeover below settles which name it answers to
364
370
  if (isUp && panePid && sameRestartParameters(prevParameters, parameters)) {
365
371
  if (prevParameters !== newParametersString) {
366
372
  await fs.promises.writeFile(parameterPath, newParametersString);
367
373
  }
368
- // We may not have launched this one (we restarted, or it predates process records), so make sure it is recorded before leaving it alone
369
- await ensureProcessRecord({ screenName, folder, panePid, record: desired });
370
- await syncParametersTimelineFiles(folder, [{ pid: parseInt(panePid) || undefined, aliveWindow: desired.aliveWindow, parameters }]);
371
- // A restart loses the timer that retires the version this one replaces, so it is put back every time we see it still waiting
372
- if (desired.takeoverAt !== undefined) {
373
- scheduleTakeover({ canonicalScreenName, folder, killTime: desired.takeoverAt });
374
+ // We may not have started this one (we restarted, or it predates process records), so make sure it is recorded before leaving it alone
375
+ await ensureProcessRecord({ screenName, folder, panePid, record });
376
+ await syncParametersTimelineFiles(folder, [{ pid: parseInt(panePid) || undefined, aliveWindow, parameters }]);
377
+ if (takeoverAt !== undefined) {
378
+ if (!canonicalUp) {
379
+ // 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
380
+ console.log(red(`Nothing is running as ${canonicalScreenName}, so ${screenName} takes it over now instead of waiting until ${new Date(takeoverAt).toISOString()}`));
381
+ await takeoverScreenLocked({ canonicalScreenName, folder });
382
+ } else {
383
+ // 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.
384
+ scheduleTakeover({ canonicalScreenName, folder, killTime: takeoverAt });
385
+ }
374
386
  }
375
387
  console.log(green(`Verified ${magenta(screenName)} is running`));
376
388
  return;
377
389
  }
378
390
 
379
- console.log(`Resyncing ${magenta(screenName)}, with ${newParametersString}, isUp = ${isUp}`);
391
+ // RUNNING is wrong - either nothing is alive, or what is alive is the wrong version. Starting is the only thing that fixes it.
392
+ if (!isUp) {
393
+ // Nothing should ever stop on its own: either it crashed, or it never came up
394
+ console.log(red(`Nothing is running as ${screenName}, starting it: ${newParametersString}`));
395
+ } else {
396
+ // SMOOTH PATH: the previous version is up and this replaces it with a new one, which is exactly what a deploy is
397
+ console.log(green(`Starting a new version as ${magenta(screenName)}, replacing the one running there: ${newParametersString}`));
398
+ }
399
+
400
+ // 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
401
+ let gitFolder = folder + "git/";
402
+ await fs.promises.mkdir(gitFolder, { recursive: true });
403
+ if (parameters.gitRef) {
404
+ let prevGitRef = "";
405
+ try {
406
+ prevGitRef = await getGitRefLive(gitFolder);
407
+ } catch { }
408
+ await ensureGitSynced({ gitFolder, repoUrl: parameters.repoUrl, gitRef: parameters.gitRef });
409
+ let afterGitRef = await getGitRefLive(gitFolder);
410
+ // 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.
411
+ let nodeModulesMissing = !await fsExistsAsync(gitFolder + "node_modules");
412
+ if (afterGitRef !== prevGitRef || nodeModulesMissing) {
413
+ console.log(green(`Yarn installing for ${magenta(screenName)} (ref ${prevGitRef} -> ${afterGitRef}, nodeModulesMissing=${nodeModulesMissing})`));
414
+ await runPromise(`yarn install --mutex network`, { cwd: gitFolder });
415
+ }
416
+ }
417
+
380
418
  await fs.promises.writeFile(parameterPath, newParametersString);
381
419
  void recordLaunch({
382
420
  serviceId,
383
- serviceKey: desired.serviceKey,
421
+ serviceKey: parameters.key,
384
422
  screenName: canonicalScreenName,
385
- machineId: desired.machineId,
423
+ machineId,
386
424
  reason: isUp && "update" || "crashed",
387
425
  time: Date.now(),
388
426
  });
@@ -390,17 +428,17 @@ async function ensureScreen(config: {
390
428
  // The node the version being replaced registered as, read before the launch overwrites the file with the new process's
391
429
  let replacedNodeId = await readServiceNodeId(folder);
392
430
 
393
- // Every launch goes into the future name and renames over the canonical one, releases and plain restarts alike: the replacement is up before anything touches the old process, and it always gets a brand new pane - which is what makes it a distinct process with its own log.
394
- let launchScreenName = getFutureScreenName(canonicalScreenName);
431
+ // 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.
432
+ let launchScreenName = futureScreenName;
395
433
  let launchedPid = await runScreenCommand({
396
434
  screenName: launchScreenName,
397
435
  folder,
398
436
  command: parameters.command,
399
- record: desired,
437
+ record,
400
438
  });
401
- // The command is running as of the line above, so the overlap is measured from here
402
- if (desired.takeoverAt !== undefined) {
403
- scheduleTakeover({ canonicalScreenName, folder, killTime: desired.takeoverAt, replacedNodeId });
439
+ // SMOOTH PATH: the command is running as of the line above, so the overlap the version it replaces gets is measured from here
440
+ if (takeoverAt !== undefined) {
441
+ scheduleTakeover({ canonicalScreenName, folder, killTime: takeoverAt, replacedNodeId });
404
442
  }
405
443
 
406
444
  await delay(LAUNCH_VERIFY_DELAY);
@@ -411,26 +449,28 @@ async function ensureScreen(config: {
411
449
  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.`);
412
450
  }
413
451
  console.log(green(`${magenta(launchScreenName)} is verified to be running (pid ${launchedPid})`));
414
- if (desired.takeoverAt === undefined) {
415
- // Nothing is holding the canonical name open for an overlap, so take it now
416
- await takeoverScreen({ canonicalScreenName, folder, replacedNodeId });
452
+ // 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.
453
+ // AFTER: EXISTS correct VERSION correct RUNNING correct, under the canonical name, or under the future one with the version it is replacing still serving
454
+ if (takeoverAt === undefined || !canonicalUp) {
455
+ await takeoverScreenLocked({ canonicalScreenName, folder, replacedNodeId });
417
456
  }
418
- await syncParametersTimelineFiles(folder, [{ pid: parseInt(launchedPanePid) || undefined, aliveWindow: desired.aliveWindow, parameters }]);
457
+ await syncParametersTimelineFiles(folder, [{ pid: parseInt(launchedPanePid) || undefined, aliveWindow, parameters }]);
419
458
 
420
459
  launchCount++;
421
460
  lastLaunchedTime = Date.now();
422
- });
461
+ }));
423
462
  } catch (e: any) {
424
463
  // Counted on failure too, as a service that keeps crashing is only visible as a launch count climbing faster than everyone else's
425
464
  launchCount++;
426
465
  lastLaunchedTime = Date.now();
427
- console.error(`Error resyncing ${magenta(screenName)} (${serviceId}): ${e.stack}`);
466
+ console.error(`Error resyncing ${magenta(canonicalScreenName)} (${serviceId}): ${e.stack}`);
428
467
  machineInfo.services[serviceId].errorFromLastRun = e.stack;
429
468
  }
430
469
  machineInfo.services[serviceId].totalTimesLaunched = launchCount;
431
470
  machineInfo.services[serviceId].lastLaunchedTime = lastLaunchedTime;
432
471
  lastLaunchedTimePerService.set(serviceId, lastLaunchedTime);
433
472
  launchesPerService.set(serviceId, launchCount);
473
+ return usedScreenNames;
434
474
  }
435
475
 
436
476
  function sameRestartParameters(prevParametersJSON: string, next: ServiceParameters): boolean {
@@ -459,27 +499,23 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
459
499
  await syncProcessRecords({
460
500
  isAlive: async record => {
461
501
  if (!running.has(record.pid)) return false;
462
- return await getProcessStartTime(record.pid) === record.startTime;
502
+ // 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
503
+ return Math.abs(await getProcessStartTime(record.pid) - record.startTime) <= START_TIME_TOLERANCE;
463
504
  },
464
505
  now: Date.now(),
465
506
  });
466
507
  let root = os.homedir() + "/" + SERVICE_FOLDER;
467
- let desiredScreens = getDesiredScreens(relevantConfigs, machineId, root);
508
+ let instances = getMachineInstances(relevantConfigs, machineId);
468
509
 
469
- // The fast-path results are memoized per resync, so a folder two desired screens share is only traversed once
510
+ // The fast-path results are memoized per resync, so a folder shared by two instances is only traversed once
470
511
  gitFastPathChecks.clear();
471
512
 
472
- // Every screen 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.
473
- await Promise.all(desiredScreens.map(desired => ensureScreen({ desired, machineInfo })));
513
+ // 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.
514
+ let usedScreenNames = new Set((await Promise.all(instances.map(instance => ensureScreen({ ...instance, machineInfo })))).flat());
474
515
 
475
- // Both names count as wanted: a screen that took over during this pass answers to the canonical one now, and one that is about to take over still answers to its future name
476
- let wantedNames = new Set<string>();
477
- for (let desired of desiredScreens) {
478
- wantedNames.add(desired.screenName);
479
- wantedNames.add(desired.canonicalScreenName);
480
- }
516
+ // Anything no instance claimed is not supposed to exist
481
517
  for (let { screenName } of screenState) {
482
- if (wantedNames.has(screenName)) continue;
518
+ if (usedScreenNames.has(screenName)) continue;
483
519
  // Already being taken down. One left over from a restart is not, so it still gets cleaned up here.
484
520
  if (isRetiring(screenName)) continue;
485
521
  // Detached, so a screen that takes its full grace period to go down doesn't hold up the end of the resync
@@ -494,9 +530,6 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
494
530
  })();
495
531
  }
496
532
 
497
- // The takeover is scheduled the moment the replacement starts, so this only exists to catch up after a restart loses those timers
498
- scheduleReleaseResync(desiredScreens);
499
-
500
533
  await machineInfos.set(machineId, machineInfo);
501
534
  console.log(`${magenta(`Resynced ${relevantConfigs.length} services`)}:\n${JSON.stringify(machineInfo, null, 2)}`);
502
535
 
@@ -511,34 +544,6 @@ async function resyncServices() {
511
544
  }
512
545
  }
513
546
 
514
- // 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.
515
- let cancelReleaseResync: (() => void) | undefined;
516
- function scheduleReleaseResync(desiredScreens: DesiredScreen[]) {
517
- let now = Date.now();
518
- let next: number | undefined;
519
- for (let desired of desiredScreens) {
520
- let boundary = desired.takeoverAt;
521
- if (boundary === undefined || boundary <= now) continue;
522
- if (next === undefined || boundary < next) {
523
- next = boundary;
524
- }
525
- }
526
- if (cancelReleaseResync) {
527
- cancelReleaseResync();
528
- cancelReleaseResync = undefined;
529
- }
530
- if (next === undefined) return;
531
- console.log(`Scheduling a resync for the next release boundary at ${new Date(next).toLocaleString()}`);
532
- cancelReleaseResync = setPreciseTimeout({
533
- time: next,
534
- callback: () => {
535
- cancelReleaseResync = undefined;
536
- void resyncServices();
537
- },
538
- });
539
- }
540
-
541
-
542
547
  async function isPIDRunning(pid: string) {
543
548
  try {
544
549
  // Use process.kill with signal 0 to test if process exists without actually killing it
@@ -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.
@@ -163,11 +163,23 @@ const getAllParentPids = measureWrap(async function getAllParentPids(): Promise<
163
163
  let obj = textTableLineToObj(table) as { PID: string; PPID: string }[];
164
164
  return new Set(obj.map(x => x.PPID));
165
165
  });
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. */
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, or when neither source can answer. */
167
167
  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;
168
+ // /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
169
+ try {
170
+ let started = await runPromise(`stat -c %Y /proc/${pid}`, { quiet: true });
171
+ let seconds = parseInt(started.trim()) || 0;
172
+ if (seconds) return seconds * 1000;
173
+ } catch {
174
+ // No procfs (or the process is gone), so fall through to ps
175
+ }
176
+ try {
177
+ // Second-accurate, which is plenty to tell two processes on the same pid apart
178
+ let started = await runPromise(`date -d "$(ps -o lstart= -p ${pid})" +%s`, { quiet: true });
179
+ return (parseInt(started.trim()) || 0) * 1000;
180
+ } catch {
181
+ return 0;
182
+ }
171
183
  });
172
184
 
173
185
  export const isScreenRunningProcess = measureWrap(async function isScreenRunningProcess(pid: string): Promise<boolean> {
@@ -263,9 +275,9 @@ export async function ensureProcessRecord(config: {
263
275
  record: Omit<ProcessRecord, "pid" | "startTime" | "folder" | "screenName">;
264
276
  }): Promise<void> {
265
277
  let pid = config.panePid;
278
+ // 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
279
  let startTime = await getProcessStartTime(pid);
267
- if (!startTime) return;
268
- let existing = await readProcessRecord(config.folder, pid, startTime);
280
+ let existing = await findProcessRecordByPid(config.folder, pid);
269
281
  if (existing) {
270
282
  // A takeover renames the session, so the record follows the name it now runs under
271
283
  if (existing.screenName === config.screenName) return;
@@ -301,40 +313,46 @@ export async function takeoverScreen(config: {
301
313
  folder: string;
302
314
  /** The node the version being replaced registered as, read before the replacement overwrote the folder's file. */
303
315
  replacedNodeId?: string;
316
+ }): Promise<boolean> {
317
+ return await withScreenLock(config.canonicalScreenName, () => takeoverScreenLocked(config));
318
+ }
319
+
320
+ /** The takeover itself. Callers already holding this screen's lock use it directly - taking the lock again from inside it would wait on themselves. */
321
+ export async function takeoverScreenLocked(config: {
322
+ canonicalScreenName: string;
323
+ folder: string;
324
+ replacedNodeId?: string;
304
325
  }): Promise<boolean> {
305
326
  let { canonicalScreenName, folder, replacedNodeId } = config;
306
- return await withScreenLock(canonicalScreenName, async () => {
307
- let futurePid = await getScreenPanePid(getFutureScreenName(canonicalScreenName));
308
- if (!futurePid) return false;
309
- let futureScreenName = getFutureScreenName(canonicalScreenName);
310
- let prefix = getTmuxPrefix();
311
- let outgoingPid = await getScreenPanePid(canonicalScreenName);
312
- if (outgoingPid) {
313
- // Moved aside rather than waited on, so the canonical name is free at once and the old process winds down on its own
314
- let retiringScreenName = getRetiringScreenName(canonicalScreenName, Date.now());
315
- await runPromise(`${prefix}tmux rename-session -t ${canonicalScreenName} ${retiringScreenName}`);
316
- await renameProcessScreen({ folder, panePid: outgoingPid, screenName: retiringScreenName });
317
- if (replacedNodeId) {
318
- // The file in the folder belongs to the replacement now, so only the registration goes
319
- void removeServiceNode({ folder, nodeId: replacedNodeId, ownsFile: false });
320
- }
321
- retiringScreens.add(retiringScreenName);
322
- void killScreen({ screenName: retiringScreenName }).finally(() => {
323
- retiringScreens.delete(retiringScreenName);
324
- });
327
+ let futureScreenName = getFutureScreenName(canonicalScreenName);
328
+ let futurePid = await getScreenPanePid(futureScreenName);
329
+ if (!futurePid) return false;
330
+ let prefix = getTmuxPrefix();
331
+
332
+ let outgoingPid = await getScreenPanePid(canonicalScreenName);
333
+ if (outgoingPid) {
334
+ // Renamed out of the way immediately, so the canonical name is free at once and the old process winds down on its own
335
+ let retiringScreenName = getRetiringScreenName(canonicalScreenName, Date.now());
336
+ await runPromise(`${prefix}tmux rename-session -t ${canonicalScreenName} ${retiringScreenName}`);
337
+ await renameProcessScreen({ folder, panePid: outgoingPid, screenName: retiringScreenName });
338
+ if (replacedNodeId) {
339
+ // The file in the folder belongs to the replacement now, so only the registration goes
340
+ void removeServiceNode({ folder, nodeId: replacedNodeId, ownsFile: false });
325
341
  }
326
- console.log(green(`Renaming ${futureScreenName} to ${canonicalScreenName} (takeover complete)`));
327
- await runPromise(`${prefix}tmux rename-session -t ${futureScreenName} ${canonicalScreenName}`);
328
- await renameProcessScreen({ folder, panePid: futurePid, screenName: canonicalScreenName });
329
- return true;
330
- });
342
+ retiringScreens.add(retiringScreenName);
343
+ void killScreen({ screenName: retiringScreenName }).finally(() => {
344
+ retiringScreens.delete(retiringScreenName);
345
+ });
346
+ }
347
+ console.log(green(`Renaming ${futureScreenName} to ${canonicalScreenName} (takeover complete)`));
348
+ await runPromise(`${prefix}tmux rename-session -t ${futureScreenName} ${canonicalScreenName}`);
349
+ await renameProcessScreen({ folder, panePid: futurePid, screenName: canonicalScreenName });
350
+ return true;
331
351
  }
332
352
 
333
353
  /** Points a process's record at the session name it now runs under. The pid is the key, so this reads exactly one file. */
334
354
  async function renameProcessScreen(config: { folder: string; panePid: string; screenName: string }): Promise<void> {
335
- let startTime = await getProcessStartTime(config.panePid);
336
- if (!startTime) return;
337
- let record = await readProcessRecord(config.folder, config.panePid, startTime);
355
+ let record = await findProcessRecordByPid(config.folder, config.panePid);
338
356
  if (!record || record.screenName === config.screenName) return;
339
357
  await writeProcessRecord({ ...record, screenName: config.screenName });
340
358
  }
@@ -371,6 +389,13 @@ export function isRetiring(screenName: string): boolean {
371
389
  return retiringScreens.has(screenName);
372
390
  }
373
391
 
392
+ /** 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. */
393
+ export async function killScreenNow(screenName: string, reason: string): Promise<void> {
394
+ if (!await getScreenPanePid(screenName)) return;
395
+ console.log(red(`Unexpected screen ${screenName}: ${reason}. Killing it immediately.`));
396
+ await runPromise(`${getTmuxPrefix()}tmux kill-session -t ${screenName}`);
397
+ }
398
+
374
399
  // 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.
375
400
  export const killScreen = measureWrap(async function killScreen(config: {
376
401
  screenName: string;