querysub 0.569.0 → 0.570.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/appSecrets.ts CHANGED
@@ -23,6 +23,8 @@ export const getCloudflareCreds = lazy(async (): Promise<{ key: string; email: s
23
23
 
24
24
  /** Serves the secret keys sliftutils' getSecret expects, reading them the way querysub already does: backblaze creds off disk (getBackblazePath), cloudflare creds from the keys archives bucket (with its local file fallback). */
25
25
  export async function getAppSecret(key: string): Promise<string | undefined> {
26
+ // HACK: This is used by the storage server. Which we want to shut down immediately. That way the next instance can start up. The sword server already looks for notifications about when it's going to be shutting down and it flushes to disk early in this case, so it should be fairly safe to hard kill it.
27
+ doImmediateShutdown();
26
28
  // Import query sub so our logs go to our actual log server. Otherwise we won't know if we get any errors.
27
29
  Querysub;
28
30
  if (key.startsWith("backblaze.json.")) {
@@ -39,3 +41,21 @@ export async function getAppSecret(key: string): Promise<string | undefined> {
39
41
  }
40
42
  return undefined;
41
43
  }
44
+
45
+ let doImmediateShutdown = lazy(() => {
46
+ // NOTE: This extra code is required to actual capture ctrl+c
47
+ if (process.platform === "win32") {
48
+ var rl = require("readline").createInterface({
49
+ input: process.stdin,
50
+ output: process.stdout
51
+ });
52
+
53
+ rl.on("SIGINT", function () {
54
+ process.emit("SIGINT");
55
+ });
56
+ }
57
+ function doShutdown() {
58
+ process.exit();
59
+ }
60
+ process.on("SIGINT", doShutdown);
61
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.569.0",
3
+ "version": "0.570.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, getTmuxPrefix, getScreenState, isScreenRunningProcess, getProcessStartTime, ensureProcessRecord, runScreenCommand, killScreen, streamProcessOutput, readServiceNodeId, removeServiceNode, takeoverScreen, scheduleTakeover } from "./processManager";
29
+ import { getScreenName, getFutureScreenName, RETIRING_MARKER, getTmuxPrefix, getScreenState, isScreenRunningProcess, getProcessStartTime, ensureProcessRecord, runScreenCommand, killScreen, streamProcessOutput, readServiceNodeId, removeServiceNode, takeoverScreen, withScreenLock } from "./processManager";
30
30
  import { ProcessRecord, syncProcessRecords, writeProcessRecord, listProcessRecords, findRunningRecord, getProcessLogPath } from "./processLogs";
31
31
 
32
32
 
@@ -220,116 +220,210 @@ const ensureGitSynced = measureWrap(async function ensureGitSynced(config: {
220
220
  let launchesPerService = new Map<string, number>();
221
221
  let lastLaunchedTimePerService = new Map<string, number>();
222
222
 
223
- const RELEASE_PREP_LEAD_TIME = timeInSecond * 30;
223
+ const LAUNCH_VERIFY_DELAY = 2000;
224
224
 
225
- // Syncs the canonical folder to the new parameters (git + yarn install + parameters.json) and creates the future screen, which just echos when it will start. Idempotent.
226
- type FutureConfig = {
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 */
227
230
  canonicalScreenName: string;
228
- next: ServiceParameters;
229
- releaseTime: number;
230
- /** When the old process's overlap runs out, so the takeover can be scheduled the moment the new one starts */
231
- killTime: number;
231
+ /** The instance folder, which is always the canonical screen's folder (an incoming version runs out of the same one) */
232
+ folder: string;
232
233
  serviceId: string;
234
+ serviceKey: string;
233
235
  index: number;
234
236
  machineId: string;
235
- screenNamesUsed: Set<string>;
236
- screenStateMap: Map<string, { screenName: string; isProcessRunning: boolean; pid: 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;
237
244
  };
238
245
 
239
- async function ensureFuturePrepared(config: FutureConfig) {
240
- let { canonicalScreenName, next, releaseTime } = config;
241
- let futureScreenName = getFutureScreenName(canonicalScreenName);
242
- config.screenNamesUsed.add(futureScreenName);
243
- let folder = os.homedir() + "/" + SERVICE_FOLDER + canonicalScreenName + "/";
244
- let gitFolder = folder + "git/";
245
- await fs.promises.mkdir(gitFolder, { recursive: true });
246
- if (next.gitRef) {
247
- let prevGitRef = "";
248
- try {
249
- prevGitRef = await getGitRefLive(gitFolder);
250
- } catch { }
251
- await ensureGitSynced({
252
- gitFolder,
253
- repoUrl: next.repoUrl,
254
- gitRef: next.gitRef,
255
- });
256
- let afterGitRef = await getGitRefLive(gitFolder);
257
- let nodeModulesMissing = !await fsExistsAsync(gitFolder + "node_modules");
258
- if (afterGitRef !== prevGitRef || nodeModulesMissing) {
259
- console.log(green(`Yarn installing for ${magenta(futureScreenName)} (ref ${prevGitRef} -> ${afterGitRef}, nodeModulesMissing=${nodeModulesMissing})`));
260
- await runPromise(`yarn install --mutex network`, { cwd: gitFolder });
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();
250
+ 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), {});
261
288
  }
262
289
  }
263
- await fs.promises.writeFile(folder + "parameters.json", JSON.stringify(next));
264
- if (!config.screenStateMap.has(futureScreenName)) {
265
- let prefix = getTmuxPrefix();
266
- await runPromise(`${prefix}tmux new -s ${futureScreenName} -d`);
267
- await runPromise(`${prefix}tmux send-keys -t ${futureScreenName} 'echo "Waiting to start the new version at ${new Date(releaseTime).toISOString()} (in ${Math.round((releaseTime - Date.now()) / timeInSecond)} seconds)"' Enter`);
268
- console.log(magenta(`Prepared future screen ${futureScreenName}, starting at ${new Date(releaseTime).toLocaleString()}`));
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);
269
294
  }
295
+ return desired;
270
296
  }
271
297
 
272
- // Starts (or verifies) the future screen actually running the new version, in the canonical screen's folder. Idempotent.
273
- async function ensureFutureStarted(config: FutureConfig) {
274
- let { canonicalScreenName, next } = config;
275
- let futureScreenName = getFutureScreenName(canonicalScreenName);
276
- let existing = config.screenStateMap.get(futureScreenName);
277
- if (existing?.isProcessRunning) {
278
- config.screenNamesUsed.add(futureScreenName);
279
- // Started before we restarted, so record it rather than only ever recording processes we launched ourselves
280
- await ensureProcessRecord({
281
- screenName: futureScreenName,
282
- folder: os.homedir() + "/" + SERVICE_FOLDER + canonicalScreenName + "/",
283
- panePid: existing.pid,
284
- record: {
285
- serviceId: config.serviceId,
286
- serviceKey: config.next.key,
287
- index: config.index,
288
- machineId: config.machineId,
289
- parameters: config.next,
290
- },
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. */
299
+ async function ensureScreen(config: {
300
+ desired: DesiredScreen;
301
+ screenStateMap: Map<string, { screenName: string; isProcessRunning: boolean; pid: string }>;
302
+ machineInfo: MachineInfo;
303
+ }): Promise<void> {
304
+ let { desired, screenStateMap, machineInfo } = config;
305
+ let { screenName, canonicalScreenName, folder, parameters, serviceId } = desired;
306
+
307
+ let launchCount = launchesPerService.get(serviceId) || 0;
308
+ let lastLaunchedTime = lastLaunchedTimePerService.get(serviceId) || 0;
309
+ machineInfo.services[serviceId] = {
310
+ lastLaunchedTime,
311
+ errorFromLastRun: "",
312
+ totalTimesLaunched: launchCount,
313
+ nodeId: "",
314
+ };
315
+
316
+ try {
317
+ await withScreenLock(canonicalScreenName, async () => {
318
+ let screen = screenStateMap.get(screenName);
319
+
320
+ if (desired.outgoing) {
321
+ // 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
322
+ if (screen?.isProcessRunning) {
323
+ await ensureProcessRecord({ screenName, folder, panePid: screen.pid, record: desired });
324
+ void (async () => {
325
+ let running = await findRunningRecord(folder, screenName);
326
+ let nodeId = running?.nodeId || await readServiceNodeId(folder);
327
+ if (nodeId) {
328
+ await notifyNodeShutdown(screenName, nodeId, desired.aliveWindow[1]);
329
+ }
330
+ })();
331
+ }
332
+ return;
333
+ }
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
+ }
350
+ }
351
+
352
+ let parameterPath = folder + "parameters.json";
353
+ let prevParameters = "";
354
+ if (await fsExistsAsync(parameterPath)) {
355
+ prevParameters = await fs.promises.readFile(parameterPath, "utf8");
356
+ }
357
+ let newParametersString = JSON.stringify(parameters);
358
+
359
+ let nodePathId = folder + SERVICE_NODE_FILE_NAME;
360
+ if (await fsExistsAsync(nodePathId)) {
361
+ machineInfo.services[serviceId].nodeId = await fs.promises.readFile(nodePathId, "utf8");
362
+ }
363
+
364
+ if (sameRestartParameters(prevParameters, parameters) && screen?.isProcessRunning) {
365
+ if (prevParameters !== newParametersString) {
366
+ await fs.promises.writeFile(parameterPath, newParametersString);
367
+ }
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: screen.pid, record: desired });
370
+ await syncParametersTimelineFiles(folder, [{ pid: parseInt(screen.pid) || undefined, aliveWindow: desired.aliveWindow, parameters }]);
371
+ console.log(green(`Verified ${magenta(screenName)} is running`));
372
+ return;
373
+ }
374
+
375
+ console.log(`Resyncing ${magenta(screenName)}, with ${newParametersString}, isRunning = ${screen?.isProcessRunning}`);
376
+ await fs.promises.writeFile(parameterPath, newParametersString);
377
+ void recordLaunch({
378
+ serviceId,
379
+ serviceKey: desired.serviceKey,
380
+ screenName: canonicalScreenName,
381
+ machineId: desired.machineId,
382
+ reason: screen?.isProcessRunning && "update" || "crashed",
383
+ time: Date.now(),
384
+ });
385
+
386
+ // 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.
387
+ let launchScreenName = getFutureScreenName(canonicalScreenName);
388
+ let launchId = await runScreenCommand({
389
+ screenName: launchScreenName,
390
+ folder,
391
+ command: parameters.command,
392
+ record: desired,
393
+ // Scheduled the instant the command is sent. Without a time the old process goes as soon as this one is verified.
394
+ takeover: desired.takeoverAt !== undefined && { canonicalScreenName, killTime: desired.takeoverAt } || undefined,
395
+ });
396
+
397
+ await delay(LAUNCH_VERIFY_DELAY);
398
+ let launched = (await getScreenState(false)).find(x => x.screenName === launchScreenName);
399
+ let isRunning = launched && await isScreenRunningProcess(launched.pid);
400
+ if (!launched || !isRunning) {
401
+ let prefix = getTmuxPrefix();
402
+ let logs = await runPromise(`${prefix}tmux capture-pane -t ${launchScreenName} -p`);
403
+ throw new Error(`${logs}\n\nService ${magenta(launchScreenName)} is not running after starting. Trying again in ${formatTime(MACHINE_RESYNC_INTERVAL)}, or on next change. Last logs above.`);
404
+ }
405
+ console.log(green(`${magenta(launchScreenName)} is verified to be running (process ${launchId})`));
406
+ if (desired.takeoverAt === undefined) {
407
+ // Nothing is holding the canonical name open for an overlap, so take it now
408
+ await takeoverScreen({ canonicalScreenName, folder });
409
+ screenStateMap.set(canonicalScreenName, { ...launched, screenName: canonicalScreenName });
410
+ }
411
+ await syncParametersTimelineFiles(folder, [{ pid: parseInt(launched.pid) || undefined, aliveWindow: desired.aliveWindow, parameters }]);
412
+
413
+ launchCount++;
414
+ lastLaunchedTime = Date.now();
291
415
  });
292
- console.log(green(`Verified future instance ${magenta(futureScreenName)} is running`));
293
- return;
416
+ } catch (e: any) {
417
+ // Counted on failure too, as a service that keeps crashing is only visible as a launch count climbing faster than everyone else's
418
+ launchCount++;
419
+ lastLaunchedTime = Date.now();
420
+ console.error(`Error resyncing ${magenta(screenName)} (${serviceId}): ${e.stack}`);
421
+ machineInfo.services[serviceId].errorFromLastRun = e.stack;
294
422
  }
295
- await ensureFuturePrepared(config);
296
- let folder = os.homedir() + "/" + SERVICE_FOLDER + canonicalScreenName + "/";
297
- console.log(green(`Starting future instance ${magenta(futureScreenName)} (release time reached)`));
298
- await runScreenCommand({
299
- screenName: futureScreenName,
300
- folder,
301
- command: next.command,
302
- record: {
303
- serviceId: config.serviceId,
304
- serviceKey: next.key,
305
- index: config.index,
306
- machineId: config.machineId,
307
- parameters: next,
308
- },
309
- });
310
- // The old process's overlap is counted from here, not from whenever a resync next comes around
311
- scheduleTakeover({ canonicalScreenName, folder, killTime: config.killTime });
312
- }
313
-
314
- /** The loop's route into the takeover: the timer scheduled at launch normally gets there first, and this is what catches up if we restarted and lost it. The takeover itself is shared, and locked, so the two can't interleave. */
315
- async function takeoverFutureScreen(config: {
316
- canonicalScreenName: string;
317
- screenNamesUsed: Set<string>;
318
- screenStateMap: Map<string, { screenName: string; isProcessRunning: boolean; pid: string }>;
319
- }) {
320
- let { canonicalScreenName, screenStateMap } = config;
321
- let futureScreenName = getFutureScreenName(canonicalScreenName);
322
- let future = screenStateMap.get(futureScreenName);
323
- if (!future) return;
324
- // The renames make both the future and the retiring names vanish from the caller's screen list, so the unused-screen sweep must not try to kill them
325
- config.screenNamesUsed.add(futureScreenName);
326
- let tookOver = await takeoverScreen({
327
- canonicalScreenName,
328
- folder: os.homedir() + "/" + SERVICE_FOLDER + canonicalScreenName + "/",
329
- });
330
- if (!tookOver) return;
331
- screenStateMap.set(canonicalScreenName, { ...future, screenName: canonicalScreenName });
332
- screenStateMap.delete(futureScreenName);
423
+ machineInfo.services[serviceId].totalTimesLaunched = launchCount;
424
+ machineInfo.services[serviceId].lastLaunchedTime = lastLaunchedTime;
425
+ lastLaunchedTimePerService.set(serviceId, lastLaunchedTime);
426
+ launchesPerService.set(serviceId, launchCount);
333
427
  }
334
428
 
335
429
  function sameRestartParameters(prevParametersJSON: string, next: ServiceParameters): boolean {
@@ -363,308 +457,29 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
363
457
  },
364
458
  now: Date.now(),
365
459
  });
366
- let screenNamesUsed = new Set<string>();
367
- let upcomingReleases: { releaseTime: number; overlapTime: number }[] = [];
368
460
  let root = os.homedir() + "/" + SERVICE_FOLDER;
461
+ let desiredScreens = getDesiredScreens(relevantConfigs, machineId, root);
369
462
 
370
- let recordStates = relevantConfigs.map(record => {
371
- let releaseTime = record.parameters.releaseTime || 0;
372
- // The overlap length always comes from the NEWEST parameters, even though it governs how long the OLD instances outlive the release
373
- let overlapTime = record.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME;
374
- let killTime = releaseTime + overlapTime;
375
- // The release dance only exists while there are old parameters to keep alive; past killTime this is just a normal config (the takeover in the canonical loop below is what gets it there)
376
- let releaseInFlight = !!releaseTime && !!record.oldParameters && Date.now() < killTime;
377
- let isPrepTime = releaseInFlight && Date.now() >= releaseTime - RELEASE_PREP_LEAD_TIME;
378
- let isOverlapTime = releaseInFlight && Date.now() >= releaseTime;
379
- // The canonical screens keep running the OLD parameters (completely untouched) until killTime; the new version runs in "-future" screens in the SAME folders from releaseTime, and takes over the canonical screen names at killTime
380
- let config = { ...record, parameters: releaseInFlight && record.oldParameters || getLiveServiceParameters(record) };
381
- return { record, config, releaseTime, overlapTime, killTime, releaseInFlight, isPrepTime, isOverlapTime };
382
- });
383
-
384
- // Fire every instance folder's git fast-path check (HEAD ref + dirty scan) in parallel up front, so the serial loop below just awaits the shared promises instead of paying for one working-tree traversal at a time
463
+ // Fire every folder's git fast-path check (HEAD ref + dirty scan) up front, so converging awaits shared promises instead of paying for one working-tree traversal at a time
385
464
  gitFastPathChecks.clear();
386
- for (let { record, config, isPrepTime } of recordStates) {
387
- const prewarm = (parameters: ServiceParameters) => {
388
- if (!parameters.gitRef) return;
389
- let targets = getMachineTargets(parameters).filter(target => target.machineId === machineId);
390
- for (let i = 0; i < targets.length; i++) {
391
- let folder = root + getScreenName({ serviceKey: parameters.key, index: i }) + "/";
392
- void checkGitSyncedAndClean(folder + "git/", parameters.gitRef);
393
- }
394
- };
395
- if (config.parameters.deploy) {
396
- prewarm(config.parameters);
397
- }
398
- if (isPrepTime && record.parameters.deploy) {
399
- prewarm(record.parameters);
400
- }
465
+ for (let desired of desiredScreens) {
466
+ if (desired.outgoing || !desired.parameters.gitRef) continue;
467
+ void checkGitSyncedAndClean(desired.folder + "git/", desired.parameters.gitRef);
401
468
  }
402
469
 
403
- // Services run concurrently: each has its own folders, its own screens and its own records, and yarn install takes a network mutex, so there is nothing for them to serialize behind. One slow service (a git sync, a yarn install, a screen taking its full shutdown grace) no longer delays every service after it.
404
- await Promise.all(recordStates.map(async ({ record, config, releaseTime, overlapTime, killTime, releaseInFlight, isPrepTime, isOverlapTime }) => {
405
- if (releaseInFlight) {
406
- upcomingReleases.push({ releaseTime, overlapTime });
407
- console.log(magenta(`Release in flight for ${record.serviceId} (${record.parameters.key}): the new version starts at ${new Date(releaseTime).toLocaleString()}${isOverlapTime && " (overlap running)" || ""}, the old instances are killed at ${new Date(killTime).toLocaleString()}`));
408
- }
409
-
410
- if (isPrepTime && record.parameters.deploy) {
411
- let nextTargets = getMachineTargets(record.parameters).filter(target => target.machineId === machineId);
412
- for (let i = 0; i < nextTargets.length; i++) {
413
- let next = { ...record.parameters, command: applyCommandTemplate(record.parameters.command, nextTargets[i].variables) };
414
- let canonicalScreenName = getScreenName({ serviceKey: record.parameters.key, index: i });
415
- try {
416
- if (isOverlapTime) {
417
- await ensureFutureStarted({ canonicalScreenName, next, releaseTime, killTime, serviceId: record.serviceId, index: i, machineId, screenNamesUsed, screenStateMap });
418
- } else {
419
- await ensureFuturePrepared({ canonicalScreenName, next, releaseTime, killTime, serviceId: record.serviceId, index: i, machineId, screenNamesUsed, screenStateMap });
420
- }
421
- // The canonical loop below writes the timeline, but it is skipped entirely when the OLD parameters aren't deployed (a release that turns deploy on). The new instance would then run with no timeline file saying when it takes over, so it is written here instead. The old parameters aren't running, so the future entry is the whole timeline.
422
- if (!config.parameters.deploy) {
423
- let futureScreen = screenStateMap.get(getFutureScreenName(canonicalScreenName));
424
- await syncParametersTimelineFiles(root + canonicalScreenName + "/", [{
425
- pid: futureScreen?.isProcessRunning && parseInt(futureScreen.pid) || undefined,
426
- aliveWindow: [releaseTime, ALIVE_WINDOW_FOREVER],
427
- parameters: next,
428
- }]);
429
- }
430
- } catch (e: any) {
431
- console.error(`Error preparing/starting the future instance for ${record.serviceId} (${record.parameters.key}): ${e.stack}`);
432
- }
433
- }
434
- }
435
- if (!config.parameters.deploy) return;
436
- let matchedTargets = getMachineTargets(config.parameters).filter(target => target.machineId === machineId);
437
- for (let i = 0; i < matchedTargets.length; i++) {
438
- // Each instance runs the command with its own entry's template variables resolved, so the per-instance parameters (written to parameters.json and compared for restarts) carry the resolved command.
439
- let instanceParameters = { ...config.parameters, command: applyCommandTemplate(config.parameters.command, matchedTargets[i].variables) };
440
- let screenName = getScreenName({
441
- serviceKey: config.parameters.key,
442
- index: i,
443
- });
444
-
445
- let launchCount = launchesPerService.get(config.serviceId) || 0;
446
- let lastLaunchedTime = lastLaunchedTimePerService.get(config.serviceId) || 0;
447
- machineInfo.services[config.serviceId] = {
448
- lastLaunchedTime,
449
- errorFromLastRun: "",
450
- totalTimesLaunched: launchCount,
451
- nodeId: "",
452
- };
453
- try {
454
- let folder = root + screenName + "/";
455
- screenNamesUsed.add(screenName);
456
-
457
- // Set once the future screen has become the canonical one, so the timeline stops listing it as a separate upcoming version
458
- let tookOverFuture = false;
459
-
460
- const syncTimeline = async (pid: string | undefined) => {
461
- // The windows overlap: a version runs from its releaseTime until the NEXT version's releaseTime + overlapTime (the overlap is when both run)
462
- let aliveWindow: [number, number] = [config.parameters.releaseTime || 0, releaseInFlight ? killTime : ALIVE_WINDOW_FOREVER];
463
- if (tookOverFuture) {
464
- // The canonical screen IS the new version now, so it starts at the release and has no scheduled end - the old parameters' window died with the old process
465
- aliveWindow = [releaseTime, ALIVE_WINDOW_FOREVER];
466
- }
467
- let entries: ParametersTimelineEntry[] = [{
468
- pid: parseInt(pid || "") || undefined,
469
- aliveWindow,
470
- parameters: instanceParameters,
471
- }];
472
- if (releaseInFlight && !tookOverFuture) {
473
- let futureTargets = getMachineTargets(record.parameters).filter(target => target.machineId === machineId);
474
- if (i < futureTargets.length) {
475
- let futureScreen = screenStateMap.get(getFutureScreenName(screenName));
476
- entries.push({
477
- pid: futureScreen?.isProcessRunning && parseInt(futureScreen.pid) || undefined,
478
- aliveWindow: [releaseTime, ALIVE_WINDOW_FOREVER],
479
- parameters: { ...record.parameters, command: applyCommandTemplate(record.parameters.command, futureTargets[i].variables) },
480
- });
481
- }
482
- }
483
- await syncParametersTimelineFiles(folder, entries);
484
- };
485
-
486
- if (releaseInFlight) {
487
- // Tell the running instance when it will be shut down, so it (and everything talking to it) can wind down gracefully. Fire and forget: it is a courtesy to the process, and nothing about taking it down may wait on a call to the process we are taking down. The node comes off its own record, which was taken before any replacement could overwrite the folder's file.
488
- void (async () => {
489
- let running = await findRunningRecord(folder, screenName);
490
- let nodeId = running?.nodeId || await readServiceNodeId(folder);
491
- if (nodeId) {
492
- await notifyNodeShutdown(screenName, nodeId, killTime);
493
- }
494
- })();
495
- } else {
496
- // A finished release's future screen takes over here: kill the old screen, rename the future one — the new process itself is untouched, so the normal logic below just verifies it
497
- await takeoverFutureScreen({ canonicalScreenName: screenName, screenNamesUsed, screenStateMap });
498
- }
499
- if (isPrepTime) {
500
- // From prep time on, the canonical (old) screen is left completely untouched — the folder holds the NEW code and parameters by now, so the normal compare/sync logic below must not run against the old process
501
- if (screenStateMap.get(screenName)?.isProcessRunning) {
502
- // The old instance keeps running untouched through the overlap, but it still needs a record - it may have been launched before we restarted
503
- let panePid = screenStateMap.get(screenName)?.pid;
504
- if (panePid) {
505
- await ensureProcessRecord({
506
- screenName,
507
- folder,
508
- panePid,
509
- record: {
510
- serviceId: config.serviceId,
511
- serviceKey: config.parameters.key,
512
- index: i,
513
- machineId,
514
- parameters: instanceParameters,
515
- },
516
- });
517
- }
518
- await syncTimeline(screenStateMap.get(screenName)?.pid);
519
- let nodePathId = folder + SERVICE_NODE_FILE_NAME;
520
- if (await fsExistsAsync(nodePathId)) {
521
- machineInfo.services[config.serviceId].nodeId = await fs.promises.readFile(nodePathId, "utf8");
522
- }
523
- console.log(green(`Verified ${magenta(screenName)} is running (release in flight, leaving it untouched until ${new Date(killTime).toLocaleString()})`));
524
- continue;
525
- }
526
- // The old instance is dead, so there is nothing to keep alive through the overlap — take the new version over immediately
527
- console.log(magenta(`Old instance ${screenName} is not running during a release, taking the new version over immediately`));
528
- await takeoverFutureScreen({ canonicalScreenName: screenName, screenNamesUsed, screenStateMap });
529
- tookOverFuture = true;
530
- let nextTargets = getMachineTargets(record.parameters).filter(target => target.machineId === machineId);
531
- if (record.parameters.deploy && record.parameters.key === config.parameters.key && i < nextTargets.length) {
532
- instanceParameters = { ...record.parameters, command: applyCommandTemplate(record.parameters.command, nextTargets[i].variables) };
533
- }
534
- }
535
-
536
- let gitFolder = folder + "git/";
537
- await fs.promises.mkdir(gitFolder, { recursive: true });
538
- if (instanceParameters.gitRef) {
539
- let prevGitRef = "";
540
- try {
541
- prevGitRef = await getGitRefLive(gitFolder);
542
- } catch { }
543
- await ensureGitSynced({
544
- gitFolder: gitFolder,
545
- repoUrl: instanceParameters.repoUrl,
546
- gitRef: instanceParameters.gitRef,
547
- });
548
- let afterGitRef = await getGitRefLive(gitFolder);
549
- // Reinstall when the ref changed OR node_modules is missing. The latter is the real fix for the "Cannot find module 'ws'" crash: a recovery re-clone can land on the same commit, so a ref-only check would skip the install and leave the service with no node_modules. Restoring node_modules also self-heals an already-running process, since a failed `require` is never cached and the next reconnect re-resolves it.
550
- let nodeModulesMissing = !await fsExistsAsync(gitFolder + "node_modules");
551
- if (afterGitRef !== prevGitRef || nodeModulesMissing) {
552
- console.log(green(`Yarn installing for ${magenta(screenName)} (ref ${prevGitRef} -> ${afterGitRef}, nodeModulesMissing=${nodeModulesMissing})`));
553
- await runPromise(`yarn install --mutex network`, { cwd: gitFolder });
554
- }
555
- }
556
- let parameterPath = folder + "/parameters.json";
557
- let prevParameters = "";
558
- if (await fsExistsAsync(parameterPath)) {
559
- prevParameters = await fs.promises.readFile(parameterPath, "utf8");
560
- }
561
- let newParametersString = JSON.stringify(instanceParameters);
562
-
563
- let sameParameters = sameRestartParameters(prevParameters, instanceParameters);
564
- let screenIsRunning = screenStateMap.get(screenName)?.isProcessRunning;
565
-
566
- let nodePathId = folder + SERVICE_NODE_FILE_NAME;
567
- if (await fsExistsAsync(nodePathId)) {
568
- let nodeId = await fs.promises.readFile(nodePathId, "utf8");
569
- machineInfo.services[config.serviceId].nodeId = nodeId;
570
- }
571
-
572
- if (sameParameters && screenIsRunning) {
573
- if (prevParameters !== newParametersString) {
574
- await fs.promises.writeFile(parameterPath, newParametersString);
575
- }
576
- // We did not launch this process (we restarted, or it predates process records), so make sure it is recorded before we leave it alone
577
- let panePid = screenStateMap.get(screenName)?.pid;
578
- if (panePid) {
579
- await ensureProcessRecord({
580
- screenName,
581
- folder,
582
- panePid,
583
- record: {
584
- serviceId: config.serviceId,
585
- serviceKey: config.parameters.key,
586
- index: i,
587
- machineId,
588
- parameters: instanceParameters,
589
- },
590
- });
591
- }
592
- await syncTimeline(screenStateMap.get(screenName)?.pid);
593
- console.log(green(`Verified ${magenta(screenName)} is running`));
594
- continue;
595
- }
596
-
597
- console.log(`Resyncing service ${magenta(screenName)}, with ${newParametersString}, isRunning = ${screenIsRunning}, sameParameters = ${sameParameters}`);
598
-
599
- await fs.promises.writeFile(parameterPath, newParametersString);
600
-
601
- let launchReason: "crashed" | "update";
602
- if (!sameParameters) {
603
- launchReason = "update";
604
- } else {
605
- launchReason = "crashed";
606
- }
607
- void recordLaunch({
608
- serviceId: config.serviceId,
609
- serviceKey: config.parameters.key,
610
- screenName,
611
- machineId,
612
- reason: launchReason,
613
- time: Date.now(),
614
- });
615
-
616
- // Every launch goes through the future screen and a rename, releases and plain restarts alike. The new process is up before the old one is touched, so nothing waits on a shutdown, and the pane it runs in is always new - which is what makes it a distinct process with its own log.
617
- let launchScreenName = getFutureScreenName(screenName);
618
- let launchId = await runScreenCommand({
619
- screenName: launchScreenName,
620
- folder,
621
- command: instanceParameters.command,
622
- record: {
623
- serviceId: config.serviceId,
624
- serviceKey: config.parameters.key,
625
- index: i,
626
- machineId,
627
- parameters: instanceParameters,
628
- },
629
- });
630
- await delay(2000);
631
- let newScreens = await getScreenState(false);
632
- let foundScreen = newScreens.find(x => x.screenName === launchScreenName);
633
- if (!foundScreen) {
634
- console.error(`Just created screen ${launchScreenName}, but it's not in the list of screens!`);
635
- }
636
- let isRunning = foundScreen && await isScreenRunningProcess(foundScreen.pid);
637
- if (!isRunning) {
638
- let prefix = getTmuxPrefix();
639
- let logs = await runPromise(`${prefix}tmux capture-pane -t ${launchScreenName} -p`);
640
- 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.`);
641
- }
642
- // Up and verified, so the old process can go now
643
- if (foundScreen) {
644
- screenStateMap.set(launchScreenName, foundScreen);
645
- }
646
- await takeoverFutureScreen({ canonicalScreenName: screenName, screenNamesUsed, screenStateMap });
647
- console.log(green(`Service ${magenta(screenName)} is verified to be running after starting (process ${launchId})`));
648
- await syncTimeline(foundScreen?.pid);
649
-
650
- launchCount++;
651
- lastLaunchedTime = Date.now();
652
- } catch (e: any) {
653
- // Increase on failures too, as this is the only way to detect if a service is repeatedly crashing. If this is really high for one service, but not others... it's crashing a lot.
654
- launchCount++;
655
- lastLaunchedTime = Date.now();
656
- console.error(`Error resyncing service ${magenta(config.serviceId)} (${config.parameters?.key}): ${e.stack}`);
657
- machineInfo.services[config.serviceId].errorFromLastRun = e.stack;
658
- }
659
- machineInfo.services[config.serviceId].totalTimesLaunched = launchCount;
660
- machineInfo.services[config.serviceId].lastLaunchedTime = lastLaunchedTime;
661
- lastLaunchedTimePerService.set(config.serviceId, lastLaunchedTime);
662
- launchesPerService.set(config.serviceId, launchCount);
663
- }
664
- }));
470
+ // 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.
471
+ await Promise.all(desiredScreens.map(desired => ensureScreen({ desired, screenStateMap, machineInfo })));
665
472
 
473
+ // 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
474
+ let wantedNames = new Set<string>();
475
+ for (let desired of desiredScreens) {
476
+ wantedNames.add(desired.screenName);
477
+ wantedNames.add(desired.canonicalScreenName);
478
+ }
666
479
  for (let { screenName } of screenState) {
667
- if (screenNamesUsed.has(screenName)) continue;
480
+ if (wantedNames.has(screenName)) continue;
481
+ // A screen that is winding down after a takeover is already being killed on its own timer
482
+ if (screenName.includes(RETIRING_MARKER)) continue;
668
483
  // Detached, so a screen that takes its full grace period to go down doesn't hold up the end of the resync
669
484
  void (async () => {
670
485
  // Nothing is replacing this one, so its registration and the file it wrote both go
@@ -677,7 +492,8 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
677
492
  })();
678
493
  }
679
494
 
680
- scheduleReleaseResync(upcomingReleases);
495
+ // The takeover is scheduled the moment the replacement starts, so this only exists to catch up after a restart loses those timers
496
+ scheduleReleaseResync(desiredScreens);
681
497
 
682
498
  await machineInfos.set(machineId, machineInfo);
683
499
  console.log(`${magenta(`Resynced ${relevantConfigs.length} services`)}:\n${JSON.stringify(machineInfo, null, 2)}`);
@@ -693,17 +509,16 @@ async function resyncServices() {
693
509
  }
694
510
  }
695
511
 
696
- // The regular resync poll is too coarse for release boundaries (the future-screen prep, the release itself, and the kill time), so schedule an extra resync at the next boundary. The boundaries carry no logic of their own — each resync just re-evaluates the current state.
512
+ // The regular resync poll is too coarse for a takeover boundary, so schedule an extra resync at the next one. It carries no logic of its own — the resync just re-evaluates the current state, which is what recovers a takeover whose timer we lost by restarting.
697
513
  let cancelReleaseResync: (() => void) | undefined;
698
- function scheduleReleaseResync(upcomingReleases: { releaseTime: number; overlapTime: number }[]) {
514
+ function scheduleReleaseResync(desiredScreens: DesiredScreen[]) {
699
515
  let now = Date.now();
700
516
  let next: number | undefined;
701
- for (let release of upcomingReleases) {
702
- for (let boundary of [release.releaseTime - RELEASE_PREP_LEAD_TIME, release.releaseTime, release.releaseTime + release.overlapTime]) {
703
- if (boundary <= now) continue;
704
- if (next === undefined || boundary < next) {
705
- next = boundary;
706
- }
517
+ for (let desired of desiredScreens) {
518
+ let boundary = desired.takeoverAt;
519
+ if (boundary === undefined || boundary <= now) continue;
520
+ if (next === undefined || boundary < next) {
521
+ next = boundary;
707
522
  }
708
523
  }
709
524
  if (cancelReleaseResync) {
@@ -25,9 +25,11 @@ 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-";
28
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. */
29
31
  export function getRetiringScreenName(canonicalScreenName: string, at: number): string {
30
- return canonicalScreenName.slice(0, -SCREEN_SUFFIX.length) + `-retiring-${at}` + SCREEN_SUFFIX;
32
+ return canonicalScreenName.slice(0, -SCREEN_SUFFIX.length) + RETIRING_MARKER + at + SCREEN_SUFFIX;
31
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.
33
35
  export function getFutureScreenName(canonicalScreenName: string): string {
@@ -215,6 +217,8 @@ export const runScreenCommand = measureWrap(async function runScreenCommand(conf
215
217
  folder?: string;
216
218
  // Identifies the process this launch creates, so its log and its configuration are stored against it
217
219
  record: Omit<ProcessRecord, "launchId" | "folder" | "screenName" | "startTime">;
220
+ // The screen this launch replaces, and when that one's overlap runs out. The timer is set the instant the command is sent, so the overlap is measured from the new process actually starting.
221
+ takeover?: { canonicalScreenName: string; killTime: number };
218
222
  }): Promise<string> {
219
223
  let prefix = getTmuxPrefix();
220
224
  let screenName = config.screenName;
@@ -259,6 +263,14 @@ ${config.command}
259
263
  `;
260
264
  await fs.promises.writeFile(folder + "command.sh", command);
261
265
  await runPromise(`${prefix}tmux send-keys -t ${screenName} 'bash ../command.sh' Enter`);
266
+ // The command is running as of this line, so the overlap starts counting here - nothing between this and the timer.
267
+ if (config.takeover) {
268
+ scheduleTakeover({
269
+ canonicalScreenName: config.takeover.canonicalScreenName,
270
+ folder,
271
+ killTime: config.takeover.killTime,
272
+ });
273
+ }
262
274
 
263
275
  await setupPipePane({ screenName, folder, launchId });
264
276
  return launchId;