querysub 0.571.0 → 0.572.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.571.0",
3
+ "version": "0.572.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
 
@@ -222,86 +222,65 @@ let lastLaunchedTimePerService = new Map<string, number>();
222
222
 
223
223
  const LAUNCH_VERIFY_DELAY = 2000;
224
224
 
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();
225
+ /** Every instance of every service that belongs to this machine, with its config already resolved for that instance - the command has this entry's template variables substituted, and a version with no entry at this index is marked as deploying nothing here. */
226
+ function getMachineInstances(configs: ServiceConfig[], machineId: string): { config: ServiceConfig; index: number }[] {
227
+ let instances: { config: ServiceConfig; index: number }[] = [];
250
228
  for (let config of configs) {
251
- 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), {});
229
+ // A release can add or remove instances, so the count is whichever version asks for the most
230
+ let count = 0;
231
+ for (let parameters of [config.parameters, config.oldParameters]) {
232
+ if (!parameters) continue;
233
+ count = Math.max(count, getMachineTargets(parameters).filter(x => x.machineId === machineId).length);
234
+ }
235
+ for (let index = 0; index < count; index++) {
236
+ const resolve = (parameters: ServiceParameters) => {
237
+ let target = getMachineTargets(parameters).filter(x => x.machineId === machineId)[index];
238
+ return {
239
+ ...parameters,
240
+ command: target && applyCommandTemplate(parameters.command, target.variables) || parameters.command,
241
+ deploy: !!parameters.deploy && !!target,
242
+ };
243
+ };
244
+ instances.push({
245
+ index,
246
+ config: {
247
+ ...config,
248
+ parameters: resolve(config.parameters),
249
+ oldParameters: config.oldParameters && resolve(config.oldParameters),
250
+ },
251
+ });
288
252
  }
289
253
  }
290
- // 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;
254
+ return instances;
296
255
  }
297
256
 
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. */
257
+ /** Puts one instance of a service into the state its configuration describes: the folder synced, the process running what it should be, and the records and timeline saying so. Returns the screens it is using, so anything nobody claimed can be killed. Everything about one screen happens under its lock, so a resync and a scheduled takeover can never interleave. */
299
258
  async function ensureScreen(config: {
300
- desired: DesiredScreen;
259
+ config: ServiceConfig;
260
+ index: number;
301
261
  machineInfo: MachineInfo;
302
- }): Promise<void> {
303
- let { desired, machineInfo } = config;
304
- let { screenName, canonicalScreenName, folder, parameters, serviceId } = desired;
262
+ }): Promise<string[]> {
263
+ let { machineInfo, index } = config;
264
+ let machineId = machineInfo.machineId;
265
+ let serviceId = config.config.serviceId;
266
+ let serviceConfig = config.config;
267
+
268
+ let releaseTime = serviceConfig.parameters.releaseTime || 0;
269
+ // The overlap length always comes from the NEWEST parameters, even though it governs how long the OLD instances outlive the release
270
+ let killTime = releaseTime + (serviceConfig.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME);
271
+ // Only in flight while there is an old version to keep alive, and only until its overlap runs out
272
+ let releaseInFlight = !!releaseTime && !!serviceConfig.oldParameters && Date.now() < killTime;
273
+
274
+ // What should be running under the canonical name now, and the version waiting to take it over
275
+ let current: ServiceParameters | undefined = releaseInFlight && serviceConfig.oldParameters || getLiveServiceParameters(serviceConfig);
276
+ if (!current?.deploy) current = undefined;
277
+ let incoming: ServiceParameters | undefined = releaseInFlight && Date.now() >= releaseTime && serviceConfig.parameters || undefined;
278
+ if (!incoming?.deploy) incoming = undefined;
279
+ let canonicalScreenName = getScreenName({ serviceKey: (current || incoming || serviceConfig.parameters).key, index });
280
+ let futureScreenName = getFutureScreenName(canonicalScreenName);
281
+ let folder = os.homedir() + "/" + SERVICE_FOLDER + canonicalScreenName + "/";
282
+ // Returned even when the work below throws, so a transient failure never makes the sweep kill a screen we still want
283
+ let usedScreenNames = [canonicalScreenName, futureScreenName];
305
284
 
306
285
  let launchCount = launchesPerService.get(serviceId) || 0;
307
286
  let lastLaunchedTime = lastLaunchedTimePerService.get(serviceId) || 0;
@@ -313,47 +292,70 @@ async function ensureScreen(config: {
313
292
  };
314
293
 
315
294
  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;
295
+ await watchSlowPromise(`ensureScreen|${canonicalScreenName}`, withScreenLock(canonicalScreenName, async () => {
296
+ /* Three facts, each moving ? -> known -> correct as we go:
297
+ EXISTS - which of the two screens are there
298
+ VERSION - which parameters they were started with
299
+ RUNNING - whether a process is actually alive in them
300
+ Every step below says where those three stand, so it is always clear what we are still assuming.
301
+ START: EXISTS ? VERSION ? RUNNING ?
302
+ */
303
+ let parameters = incoming || current;
304
+ let screenName = incoming && futureScreenName || canonicalScreenName;
305
+ let takeoverAt = incoming && killTime || undefined;
306
+
307
+ // A screen we do not expect holds something we never asked for, so it goes at once - nothing here is worth winding down. Each name is judged on its own: a release that turns deploy on has an incoming version and nothing that should be running under the canonical name yet.
308
+ // AFTER: EXISTS correct in the "nothing extra" sense. A screen that SHOULD exist may still be missing, which is RUNNING's problem below.
309
+ if (!incoming) {
310
+ await killScreenNow(futureScreenName, "there is no release in flight, so nothing should be waiting to take over");
333
311
  }
334
-
335
- 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
- }
312
+ if (!current) {
313
+ await killScreenNow(canonicalScreenName, "nothing is supposed to be serving this name yet");
350
314
  }
315
+ if (!current && !incoming) return;
351
316
 
317
+ // Both screens run out of one folder, so its parameters.json is the ONE version they were last started from - it cannot tell the two screens apart.
318
+ // AFTER: VERSION known. Anything it does not match, including a version we no longer recognize, is handled by starting the right one further down - which keeps whatever is up serving until the replacement is verified, instead of killing it now and serving nothing while we sync and start.
352
319
  let parameterPath = folder + "parameters.json";
353
320
  let prevParameters = "";
354
321
  if (await fsExistsAsync(parameterPath)) {
355
322
  prevParameters = await fs.promises.readFile(parameterPath, "utf8");
356
323
  }
324
+
325
+ // AFTER: RUNNING known for every name that can still have anything in it. A screen can exist with nothing alive in it, and one that should exist can be missing entirely - both read as not up, and both are fixed the same way. Without an incoming version the future name was just killed, so there is nothing left to ask about it.
326
+ let canonicalPid = await getScreenPanePid(canonicalScreenName);
327
+ let canonicalUp = !!canonicalPid && await isScreenRunningProcess(canonicalPid);
328
+ let futurePid = incoming && await getScreenPanePid(futureScreenName) || undefined;
329
+ let futureUp = !!futurePid && await isScreenRunningProcess(futurePid);
330
+
331
+ // SMOOTH PATH: this is what a deploy looks like from the canonical name's side. The version being replaced is already correct - it keeps running untouched until its overlap ends, so this name is settled and only the future one is left to converge. It needs its record kept straight, and to hear when it goes down.
332
+ // AFTER (canonical, during a release): EXISTS correct VERSION correct RUNNING correct
333
+ if (incoming && current && !canonicalUp) {
334
+ console.log(red(`Nothing is running as ${canonicalScreenName}, which should be serving until ${new Date(killTime).toISOString()} while its replacement starts`));
335
+ }
336
+ if (incoming && current && canonicalUp && canonicalPid) {
337
+ await ensureProcessRecord({
338
+ screenName: canonicalScreenName,
339
+ folder,
340
+ panePid: canonicalPid,
341
+ record: { serviceId, serviceKey: current.key, index, machineId, parameters: current },
342
+ });
343
+ void (async () => {
344
+ let nodeId = await readServiceNodeId(folder);
345
+ if (nodeId) {
346
+ await notifyNodeShutdown(canonicalScreenName, nodeId, killTime);
347
+ }
348
+ })();
349
+ }
350
+
351
+ // From here on this is about ONE screen - the future one during a release, the canonical one otherwise. Everything else is already correct.
352
+ // STATE: EXISTS correct VERSION known RUNNING known
353
+ if (!parameters) return;
354
+ let panePid = incoming && futurePid || canonicalPid;
355
+ let isUp = incoming && futureUp || canonicalUp;
356
+ let aliveWindow: [number, number] = [parameters.releaseTime || 0, ALIVE_WINDOW_FOREVER];
357
+ let record = { serviceId, serviceKey: parameters.key, index, machineId, parameters };
358
+
357
359
  let newParametersString = JSON.stringify(parameters);
358
360
 
359
361
  let nodePathId = folder + SERVICE_NODE_FILE_NAME;
@@ -361,28 +363,62 @@ async function ensureScreen(config: {
361
363
  machineInfo.services[serviceId].nodeId = await fs.promises.readFile(nodePathId, "utf8");
362
364
  }
363
365
 
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
364
368
  if (isUp && panePid && sameRestartParameters(prevParameters, parameters)) {
365
369
  if (prevParameters !== newParametersString) {
366
370
  await fs.promises.writeFile(parameterPath, newParametersString);
367
371
  }
368
- // We may not have 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 });
372
+ // We may not have started this one (we restarted, or it predates process records), so make sure it is recorded before leaving it alone
373
+ await ensureProcessRecord({ screenName, folder, panePid, record });
374
+ await syncParametersTimelineFiles(folder, [{ pid: parseInt(panePid) || undefined, aliveWindow, parameters }]);
375
+ if (takeoverAt !== undefined) {
376
+ if (!canonicalUp) {
377
+ // The version this one is waiting to replace is gone, so there is no overlap left to serve - take the name now instead of sitting under the future one
378
+ console.log(red(`Nothing is running as ${canonicalScreenName}, so ${screenName} takes it over now instead of waiting until ${new Date(takeoverAt).toISOString()}`));
379
+ await takeoverScreenLocked({ canonicalScreenName, folder });
380
+ } else {
381
+ // SMOOTH PATH: both versions are up, the old one serving and the new one waiting. A restart loses the timer that retires the old one, so it is put back every time we see it still waiting.
382
+ scheduleTakeover({ canonicalScreenName, folder, killTime: takeoverAt });
383
+ }
374
384
  }
375
385
  console.log(green(`Verified ${magenta(screenName)} is running`));
376
386
  return;
377
387
  }
378
388
 
379
- console.log(`Resyncing ${magenta(screenName)}, with ${newParametersString}, isUp = ${isUp}`);
389
+ // RUNNING is wrong - either nothing is alive, or what is alive is the wrong version. Starting is the only thing that fixes it.
390
+ if (!isUp) {
391
+ // Nothing should ever stop on its own: either it crashed, or it never came up
392
+ console.log(red(`Nothing is running as ${screenName}, starting it: ${newParametersString}`));
393
+ } else {
394
+ // SMOOTH PATH: the previous version is up and this replaces it with a new one, which is exactly what a deploy is
395
+ console.log(green(`Starting a new version as ${magenta(screenName)}, replacing the one running there: ${newParametersString}`));
396
+ }
397
+
398
+ // Only now does the folder have to hold this version, which is why the git and yarn work sits behind the check above rather than running on every resync
399
+ let gitFolder = folder + "git/";
400
+ await fs.promises.mkdir(gitFolder, { recursive: true });
401
+ if (parameters.gitRef) {
402
+ let prevGitRef = "";
403
+ try {
404
+ prevGitRef = await getGitRefLive(gitFolder);
405
+ } catch { }
406
+ await ensureGitSynced({ gitFolder, repoUrl: parameters.repoUrl, gitRef: parameters.gitRef });
407
+ let afterGitRef = await getGitRefLive(gitFolder);
408
+ // Reinstall when the ref changed OR node_modules is missing. A recovery re-clone can land on the same commit, so a ref-only check would leave the service with no node_modules.
409
+ let nodeModulesMissing = !await fsExistsAsync(gitFolder + "node_modules");
410
+ if (afterGitRef !== prevGitRef || nodeModulesMissing) {
411
+ console.log(green(`Yarn installing for ${magenta(screenName)} (ref ${prevGitRef} -> ${afterGitRef}, nodeModulesMissing=${nodeModulesMissing})`));
412
+ await runPromise(`yarn install --mutex network`, { cwd: gitFolder });
413
+ }
414
+ }
415
+
380
416
  await fs.promises.writeFile(parameterPath, newParametersString);
381
417
  void recordLaunch({
382
418
  serviceId,
383
- serviceKey: desired.serviceKey,
419
+ serviceKey: parameters.key,
384
420
  screenName: canonicalScreenName,
385
- machineId: desired.machineId,
421
+ machineId,
386
422
  reason: isUp && "update" || "crashed",
387
423
  time: Date.now(),
388
424
  });
@@ -390,17 +426,17 @@ async function ensureScreen(config: {
390
426
  // The node the version being replaced registered as, read before the launch overwrites the file with the new process's
391
427
  let replacedNodeId = await readServiceNodeId(folder);
392
428
 
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);
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;
395
431
  let launchedPid = await runScreenCommand({
396
432
  screenName: launchScreenName,
397
433
  folder,
398
434
  command: parameters.command,
399
- record: desired,
435
+ record,
400
436
  });
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 });
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 });
404
440
  }
405
441
 
406
442
  await delay(LAUNCH_VERIFY_DELAY);
@@ -411,26 +447,28 @@ async function ensureScreen(config: {
411
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.`);
412
448
  }
413
449
  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 });
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 });
417
454
  }
418
- await syncParametersTimelineFiles(folder, [{ pid: parseInt(launchedPanePid) || undefined, aliveWindow: desired.aliveWindow, parameters }]);
455
+ await syncParametersTimelineFiles(folder, [{ pid: parseInt(launchedPanePid) || undefined, aliveWindow, parameters }]);
419
456
 
420
457
  launchCount++;
421
458
  lastLaunchedTime = Date.now();
422
- });
459
+ }));
423
460
  } catch (e: any) {
424
461
  // Counted on failure too, as a service that keeps crashing is only visible as a launch count climbing faster than everyone else's
425
462
  launchCount++;
426
463
  lastLaunchedTime = Date.now();
427
- console.error(`Error resyncing ${magenta(screenName)} (${serviceId}): ${e.stack}`);
464
+ console.error(`Error resyncing ${magenta(canonicalScreenName)} (${serviceId}): ${e.stack}`);
428
465
  machineInfo.services[serviceId].errorFromLastRun = e.stack;
429
466
  }
430
467
  machineInfo.services[serviceId].totalTimesLaunched = launchCount;
431
468
  machineInfo.services[serviceId].lastLaunchedTime = lastLaunchedTime;
432
469
  lastLaunchedTimePerService.set(serviceId, lastLaunchedTime);
433
470
  launchesPerService.set(serviceId, launchCount);
471
+ return usedScreenNames;
434
472
  }
435
473
 
436
474
  function sameRestartParameters(prevParametersJSON: string, next: ServiceParameters): boolean {
@@ -464,22 +502,17 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
464
502
  now: Date.now(),
465
503
  });
466
504
  let root = os.homedir() + "/" + SERVICE_FOLDER;
467
- let desiredScreens = getDesiredScreens(relevantConfigs, machineId, root);
505
+ let instances = getMachineInstances(relevantConfigs, machineId);
468
506
 
469
- // The fast-path results are memoized per resync, so a folder two desired screens share is only traversed once
507
+ // The fast-path results are memoized per resync, so a folder shared by two instances is only traversed once
470
508
  gitFastPathChecks.clear();
471
509
 
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 })));
510
+ // Every instance converges concurrently. Each has its own folder, its own records and its own lock, and yarn install takes a network mutex, so there is nothing to serialize behind - one slow git sync or shutdown no longer delays everything after it.
511
+ let usedScreenNames = new Set((await Promise.all(instances.map(instance => ensureScreen({ ...instance, machineInfo })))).flat());
474
512
 
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
- }
513
+ // Anything no instance claimed is not supposed to exist
481
514
  for (let { screenName } of screenState) {
482
- if (wantedNames.has(screenName)) continue;
515
+ if (usedScreenNames.has(screenName)) continue;
483
516
  // Already being taken down. One left over from a restart is not, so it still gets cleaned up here.
484
517
  if (isRetiring(screenName)) continue;
485
518
  // Detached, so a screen that takes its full grace period to go down doesn't hold up the end of the resync
@@ -494,9 +527,6 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
494
527
  })();
495
528
  }
496
529
 
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
530
  await machineInfos.set(machineId, machineInfo);
501
531
  console.log(`${magenta(`Resynced ${relevantConfigs.length} services`)}:\n${JSON.stringify(machineInfo, null, 2)}`);
502
532
 
@@ -511,34 +541,6 @@ async function resyncServices() {
511
541
  }
512
542
  }
513
543
 
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
544
  async function isPIDRunning(pid: string) {
543
545
  try {
544
546
  // Use process.kill with signal 0 to test if process exists without actually killing it
@@ -301,33 +301,41 @@ export async function takeoverScreen(config: {
301
301
  folder: string;
302
302
  /** The node the version being replaced registered as, read before the replacement overwrote the folder's file. */
303
303
  replacedNodeId?: string;
304
+ }): Promise<boolean> {
305
+ return await withScreenLock(config.canonicalScreenName, () => takeoverScreenLocked(config));
306
+ }
307
+
308
+ /** The takeover itself. Callers already holding this screen's lock use it directly - taking the lock again from inside it would wait on themselves. */
309
+ export async function takeoverScreenLocked(config: {
310
+ canonicalScreenName: string;
311
+ folder: string;
312
+ replacedNodeId?: string;
304
313
  }): Promise<boolean> {
305
314
  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
- });
315
+ let futureScreenName = getFutureScreenName(canonicalScreenName);
316
+ let futurePid = await getScreenPanePid(futureScreenName);
317
+ if (!futurePid) return false;
318
+ let prefix = getTmuxPrefix();
319
+
320
+ let outgoingPid = await getScreenPanePid(canonicalScreenName);
321
+ if (outgoingPid) {
322
+ // Renamed out of the way immediately, so the canonical name is free at once and the old process winds down on its own
323
+ let retiringScreenName = getRetiringScreenName(canonicalScreenName, Date.now());
324
+ await runPromise(`${prefix}tmux rename-session -t ${canonicalScreenName} ${retiringScreenName}`);
325
+ await renameProcessScreen({ folder, panePid: outgoingPid, screenName: retiringScreenName });
326
+ if (replacedNodeId) {
327
+ // The file in the folder belongs to the replacement now, so only the registration goes
328
+ void removeServiceNode({ folder, nodeId: replacedNodeId, ownsFile: false });
325
329
  }
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
- });
330
+ retiringScreens.add(retiringScreenName);
331
+ void killScreen({ screenName: retiringScreenName }).finally(() => {
332
+ retiringScreens.delete(retiringScreenName);
333
+ });
334
+ }
335
+ console.log(green(`Renaming ${futureScreenName} to ${canonicalScreenName} (takeover complete)`));
336
+ await runPromise(`${prefix}tmux rename-session -t ${futureScreenName} ${canonicalScreenName}`);
337
+ await renameProcessScreen({ folder, panePid: futurePid, screenName: canonicalScreenName });
338
+ return true;
331
339
  }
332
340
 
333
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. */
@@ -371,6 +379,13 @@ export function isRetiring(screenName: string): boolean {
371
379
  return retiringScreens.has(screenName);
372
380
  }
373
381
 
382
+ /** Ends a session at once, with no notice - for a screen that should not exist at all, so there is nothing to wind down gracefully. Finding one is always a surprise, so the reason it was not supposed to be there is logged. */
383
+ export async function killScreenNow(screenName: string, reason: string): Promise<void> {
384
+ if (!await getScreenPanePid(screenName)) return;
385
+ console.log(red(`Unexpected screen ${screenName}: ${reason}. Killing it immediately.`));
386
+ await runPromise(`${getTmuxPrefix()}tmux kill-session -t ${screenName}`);
387
+ }
388
+
374
389
  // Kills a screen and nothing else. Which node the process registered as, and whether that registration should be dropped, is the deploy logic's business - it reads the node id off the process's record, which was taken before any replacement could overwrite the file.
375
390
  export const killScreen = measureWrap(async function killScreen(config: {
376
391
  screenName: string;