querysub 0.568.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.568.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",
@@ -82,28 +82,6 @@ export class ServiceDetailPage extends qreact.Component {
82
82
  });
83
83
  }
84
84
 
85
- private confirmForceDeployNow(deployConfig: ServiceConfig) {
86
- let modal: { close: () => void } | undefined;
87
- modal = showModal({
88
- content: <div className={css.fixed.pos(0, 0).size("100vw", "100vh").hsla(0, 0, 0, 0.5).display("flex").alignItems("center").justifyContent("center")}>
89
- <div className={css.vbox(12).pad2(20).hsl(0, 0, 12).colorhsl(0, 0, 90).bord2(0, 0, 40) + " keepModalsOpen"}>
90
- <div className={css.boldStyle.fontSize(16)}>🚀 Deploy?</div>
91
- <div>{deployConfig.parameters.overlapTime
92
- ? `This config goes live now. The old instances keep running for ${formatTime(deployConfig.parameters.overlapTime)} before shutting down.`
93
- : "This config goes live now. The old instances are shut down as soon as the new ones are up, so anything still connected to them is dropped."}</div>
94
- <div className={css.hbox(10)}>
95
- <Button hue={0} onClick={() => {
96
- modal && modal.close();
97
- deployConfig.parameters.releaseTime = Date.now();
98
- this.deployConfig(deployConfig);
99
- }}>🚀 Deploy</Button>
100
- <Button onClick={() => modal && modal.close()}>Cancel</Button>
101
- </div>
102
- </div>
103
- </div>,
104
- });
105
- }
106
-
107
85
  // Shows the `${name}` variables found in the command, and lets each picked machine entry set its own values for them (which converts machineIds to the { machineId, variables } form).
108
86
  private renderTemplateVariables(config: ServiceConfig) {
109
87
  let templateVariables = getCommandTemplateVariables(config.parameters.command);
@@ -601,7 +579,9 @@ export class ServiceDetailPage extends qreact.Component {
601
579
  disabled={this.state.isDeploying}
602
580
  title="Changes the scheduled deploy to go live immediately"
603
581
  onClick={() => {
604
- this.confirmForceDeployNow(deepCloneJSON(stripReleaseState(originalConfig)));
582
+ let deployConfig = deepCloneJSON(stripReleaseState(originalConfig));
583
+ deployConfig.parameters.releaseTime = Date.now();
584
+ this.deployConfig(deployConfig);
605
585
  }}>
606
586
  ⚡ Force Schedule Now
607
587
  </button>
@@ -656,7 +636,7 @@ export class ServiceDetailPage extends qreact.Component {
656
636
  disabled={this.state.isDeploying}
657
637
  title="Deploys now; the old instances keep running for the overlap before shutting down"
658
638
  onClick={() => {
659
- this.confirmForceDeployNow(makeDeployConfig());
639
+ this.deployConfig(makeDeployConfig());
660
640
  }}>
661
641
  {this.state.isDeploying && "⏳ Deploying..." || "🚀 Deploy"}
662
642
  </button>
@@ -665,7 +645,7 @@ export class ServiceDetailPage extends qreact.Component {
665
645
  disabled={this.state.isDeploying}
666
646
  title="Deploys now AND shuts the old instances down as soon as the new ones are up (overlap of 0)"
667
647
  onClick={() => {
668
- this.confirmForceDeployNow(makeDeployConfig(0));
648
+ this.deployConfig(makeDeployConfig(0));
669
649
  }}>
670
650
  Deploy, No Overlap
671
651
  </button>
@@ -101,32 +101,6 @@ export class UpdateButtons extends qreact.Component<{
101
101
  }
102
102
  });
103
103
  }
104
- private confirmDeployAllNow(outdatedServices: ServiceConfig[], latestRef: string, noOverlap: boolean) {
105
- let modal: { close: () => void } | undefined;
106
- modal = showFullscreenModal({
107
- content: <div className={css.vbox(12).pad2(20)}>
108
- <div className={css.boldStyle.fontSize(16)}>⚡ Deploy all {outdatedServices.length} services now?</div>
109
- <div>{noOverlap
110
- ? "The old instances are shut down as soon as the new ones are up (overlap of 0), so anything still connected to them is dropped."
111
- : "The old instances keep running for their configured overlap before shutting down, so connections have somewhere to go while clients move across."}</div>
112
- <div className={css.hbox(10)}>
113
- <button
114
- className={css.pad2(12, 8).button + (noOverlap && css.bord2(0, 90, 30).hsl(0, 85, 65).colorhsl(0, 0, 100).fontWeight("bold") || css.bord2(45, 80, 35).hsl(45, 85, 82))}
115
- onClick={() => {
116
- modal && modal.close();
117
- this.deployAll(outdatedServices, latestRef, noOverlap);
118
- }}>
119
- ⚡ Deploy Now
120
- </button>
121
- <button
122
- className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100)}
123
- onClick={() => modal && modal.close()}>
124
- Cancel
125
- </button>
126
- </div>
127
- </div>
128
- });
129
- }
130
104
  render() {
131
105
  let controller = MachineServiceController(SocketFunction.browserNodeId());
132
106
  const gitInfo = controller.getGitInfo();
@@ -185,7 +159,7 @@ export class UpdateButtons extends qreact.Component<{
185
159
  disabled={this.state.isDeploying}
186
160
  title="Deploys now; the old instances keep running for their configured overlap before shutting down"
187
161
  onClick={() => {
188
- this.confirmDeployAllNow(outdatedServices, gitInfo.latestRef, false);
162
+ this.deployAll(outdatedServices, gitInfo.latestRef, false);
189
163
  }}
190
164
  >
191
165
  <div>
@@ -205,7 +179,7 @@ export class UpdateButtons extends qreact.Component<{
205
179
  disabled={this.state.isDeploying}
206
180
  title="Deploys now AND shuts the old instances down as soon as the new ones are up (overlap of 0)"
207
181
  onClick={() => {
208
- this.confirmDeployAllNow(outdatedServices, gitInfo.latestRef, true);
182
+ this.deployAll(outdatedServices, gitInfo.latestRef, true);
209
183
  }}
210
184
  >
211
185
  <div>
@@ -26,8 +26,8 @@ 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, getRetiringScreenName, getTmuxPrefix, getScreenState, isScreenRunningProcess, getProcessStartTime, ensureProcessRecord, runScreenCommand, killScreen, streamProcessOutput } from "./processManager";
30
- import { ProcessRecord, syncProcessRecords, writeProcessRecord, listProcessRecords, getProcessLogPath } from "./processLogs";
29
+ import { getScreenName, getFutureScreenName, RETIRING_MARKER, getTmuxPrefix, getScreenState, isScreenRunningProcess, getProcessStartTime, ensureProcessRecord, runScreenCommand, killScreen, streamProcessOutput, readServiceNodeId, removeServiceNode, takeoverScreen, withScreenLock } from "./processManager";
30
+ import { ProcessRecord, syncProcessRecords, writeProcessRecord, listProcessRecords, findRunningRecord, getProcessLogPath } from "./processLogs";
31
31
 
32
32
 
33
33
 
@@ -137,17 +137,6 @@ async function notifyNodeShutdown(screenName: string, nodeId: string, time: numb
137
137
  console.warn(`Error notifying ${screenName} (${nodeId}) of its scheduled shutdown (will retry on the next resync): ${e.stack}`);
138
138
  }
139
139
  }
140
- async function notifyServiceShutdown(screenName: string, time: number) {
141
- if (notifiedShutdowns.get(screenName) === time) return;
142
- let nodeIdFile = os.homedir() + "/" + SERVICE_FOLDER + screenName + "/" + SERVICE_NODE_FILE_NAME;
143
- if (!await fsExistsAsync(nodeIdFile)) {
144
- console.log(`No nodeId file found for ${screenName}, cannot notify it of its scheduled shutdown yet (will retry on the next resync)`);
145
- return;
146
- }
147
- let nodeId = await fs.promises.readFile(nodeIdFile, "utf8");
148
- await notifyNodeShutdown(screenName, nodeId, time);
149
- }
150
-
151
140
 
152
141
 
153
142
 
@@ -231,140 +220,210 @@ const ensureGitSynced = measureWrap(async function ensureGitSynced(config: {
231
220
  let launchesPerService = new Map<string, number>();
232
221
  let lastLaunchedTimePerService = new Map<string, number>();
233
222
 
234
- const RELEASE_PREP_LEAD_TIME = timeInSecond * 30;
235
-
236
- // In-memory only: the old process's nodeId, captured just before the future instance starts (the new process overwrites the shared folder's nodeId file), so the old node can be force-removed when its screen is finally killed. Lost on apply restart, which is fine — node discovery times dead nodes out anyway.
237
- let takeoverOldNodeIds = new Map<string, string>();
223
+ const LAUNCH_VERIFY_DELAY = 2000;
238
224
 
239
- // 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.
240
- 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 */
241
230
  canonicalScreenName: string;
242
- next: ServiceParameters;
243
- releaseTime: 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;
244
233
  serviceId: string;
234
+ serviceKey: string;
245
235
  index: number;
246
236
  machineId: string;
247
- screenNamesUsed: Set<string>;
248
- 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;
249
244
  };
250
245
 
251
- async function ensureFuturePrepared(config: FutureConfig) {
252
- let { canonicalScreenName, next, releaseTime } = config;
253
- let futureScreenName = getFutureScreenName(canonicalScreenName);
254
- config.screenNamesUsed.add(futureScreenName);
255
- let folder = os.homedir() + "/" + SERVICE_FOLDER + canonicalScreenName + "/";
256
- let gitFolder = folder + "git/";
257
- await fs.promises.mkdir(gitFolder, { recursive: true });
258
- if (next.gitRef) {
259
- let prevGitRef = "";
260
- try {
261
- prevGitRef = await getGitRefLive(gitFolder);
262
- } catch { }
263
- await ensureGitSynced({
264
- gitFolder,
265
- repoUrl: next.repoUrl,
266
- gitRef: next.gitRef,
267
- });
268
- let afterGitRef = await getGitRefLive(gitFolder);
269
- let nodeModulesMissing = !await fsExistsAsync(gitFolder + "node_modules");
270
- if (afterGitRef !== prevGitRef || nodeModulesMissing) {
271
- console.log(green(`Yarn installing for ${magenta(futureScreenName)} (ref ${prevGitRef} -> ${afterGitRef}, nodeModulesMissing=${nodeModulesMissing})`));
272
- 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), {});
273
288
  }
274
289
  }
275
- await fs.promises.writeFile(folder + "parameters.json", JSON.stringify(next));
276
- if (!config.screenStateMap.has(futureScreenName)) {
277
- let prefix = getTmuxPrefix();
278
- await runPromise(`${prefix}tmux new -s ${futureScreenName} -d`);
279
- 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`);
280
- 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);
281
294
  }
295
+ return desired;
282
296
  }
283
297
 
284
- // Starts (or verifies) the future screen actually running the new version, in the canonical screen's folder. Idempotent.
285
- async function ensureFutureStarted(config: FutureConfig) {
286
- let { canonicalScreenName, next } = config;
287
- let futureScreenName = getFutureScreenName(canonicalScreenName);
288
- let existing = config.screenStateMap.get(futureScreenName);
289
- if (existing?.isProcessRunning) {
290
- config.screenNamesUsed.add(futureScreenName);
291
- // Started before we restarted, so record it rather than only ever recording processes we launched ourselves
292
- await ensureProcessRecord({
293
- screenName: futureScreenName,
294
- folder: os.homedir() + "/" + SERVICE_FOLDER + canonicalScreenName + "/",
295
- panePid: existing.pid,
296
- record: {
297
- serviceId: config.serviceId,
298
- serviceKey: config.next.key,
299
- index: config.index,
300
- machineId: config.machineId,
301
- parameters: config.next,
302
- },
303
- });
304
- console.log(green(`Verified future instance ${magenta(futureScreenName)} is running`));
305
- return;
306
- }
307
- await ensureFuturePrepared(config);
308
- let folder = os.homedir() + "/" + SERVICE_FOLDER + canonicalScreenName + "/";
309
- let nodeIdFile = folder + SERVICE_NODE_FILE_NAME;
310
- // Only the FIRST start can trust the file to hold the old process's nodeId — on a future-instance crash + restart it may already hold the future's own nodeId
311
- if (!takeoverOldNodeIds.has(canonicalScreenName) && await fsExistsAsync(nodeIdFile)) {
312
- takeoverOldNodeIds.set(canonicalScreenName, await fs.promises.readFile(nodeIdFile, "utf8"));
313
- }
314
- console.log(green(`Starting future instance ${magenta(futureScreenName)} (release time reached)`));
315
- await runScreenCommand({
316
- screenName: futureScreenName,
317
- folder,
318
- command: next.command,
319
- record: {
320
- serviceId: config.serviceId,
321
- serviceKey: next.key,
322
- index: config.index,
323
- machineId: config.machineId,
324
- parameters: next,
325
- },
326
- });
327
- }
328
-
329
- // Kills the old canonical screen (if any) and renames the future screen to the canonical name. The rename leaves the new process completely untouched, so the takeover is seamless. No-op when there is no future screen.
330
- async function takeoverFutureScreen(config: {
331
- canonicalScreenName: string;
332
- screenNamesUsed: Set<string>;
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;
333
301
  screenStateMap: Map<string, { screenName: string; isProcessRunning: boolean; pid: string }>;
334
- }) {
335
- let { canonicalScreenName, screenStateMap } = config;
336
- let futureScreenName = getFutureScreenName(canonicalScreenName);
337
- let future = screenStateMap.get(futureScreenName);
338
- if (!future) return;
339
- // The rename makes the future name vanish, but the caller's original screen list still has it — mark it used so the unused-screen cleanup doesn't try to kill a nonexistent session
340
- config.screenNamesUsed.add(futureScreenName);
341
- if (screenStateMap.has(canonicalScreenName)) {
342
- // The old screen is moved aside rather than waited on: the canonical name is free immediately, and its shutdown (Ctrl+C, then the hard kill) runs on its own without holding up this service or the next one.
343
- let retiringScreenName = getRetiringScreenName(canonicalScreenName, Date.now());
344
- let prefix = getTmuxPrefix();
345
- await runPromise(`${prefix}tmux rename-session -t ${canonicalScreenName} ${retiringScreenName}`);
346
- screenStateMap.delete(canonicalScreenName);
347
- // Kept out of the unused-screen sweep, which would kill it a second time while it is already winding down
348
- config.screenNamesUsed.add(retiringScreenName);
349
- void killScreen({ screenName: retiringScreenName, skipNodeIdRemoval: true });
350
- let oldNodeId = takeoverOldNodeIds.get(canonicalScreenName);
351
- if (oldNodeId) {
352
- void forceRemoveNode(oldNodeId);
353
- }
354
- }
355
- takeoverOldNodeIds.delete(canonicalScreenName);
356
- let prefix = getTmuxPrefix();
357
- console.log(green(`Renaming future screen ${magenta(futureScreenName)} to ${magenta(canonicalScreenName)} (takeover complete)`));
358
- await runPromise(`${prefix}tmux rename-session -t ${futureScreenName} ${canonicalScreenName}`);
359
- screenStateMap.set(canonicalScreenName, { ...future, screenName: canonicalScreenName });
360
- screenStateMap.delete(futureScreenName);
361
- // Nothing to do to the logs: the future process has been writing to its own file all along, and keeps writing to it under the new session name. Its record still names the future session though, so point it at the name it now runs under.
362
- let folder = os.homedir() + "/" + SERVICE_FOLDER + canonicalScreenName + "/";
363
- for (let record of await listProcessRecords()) {
364
- if (record.folder !== folder || record.screenName !== futureScreenName || record.deadTime !== undefined) continue;
365
- record.screenName = canonicalScreenName;
366
- await writeProcessRecord(record);
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();
415
+ });
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;
367
422
  }
423
+ machineInfo.services[serviceId].totalTimesLaunched = launchCount;
424
+ machineInfo.services[serviceId].lastLaunchedTime = lastLaunchedTime;
425
+ lastLaunchedTimePerService.set(serviceId, lastLaunchedTime);
426
+ launchesPerService.set(serviceId, launchCount);
368
427
  }
369
428
 
370
429
  function sameRestartParameters(prevParametersJSON: string, next: ServiceParameters): boolean {
@@ -398,316 +457,43 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
398
457
  },
399
458
  now: Date.now(),
400
459
  });
401
- let screenNamesUsed = new Set<string>();
402
- let upcomingReleases: { releaseTime: number; overlapTime: number }[] = [];
403
460
  let root = os.homedir() + "/" + SERVICE_FOLDER;
461
+ let desiredScreens = getDesiredScreens(relevantConfigs, machineId, root);
404
462
 
405
- let recordStates = relevantConfigs.map(record => {
406
- let releaseTime = record.parameters.releaseTime || 0;
407
- // The overlap length always comes from the NEWEST parameters, even though it governs how long the OLD instances outlive the release
408
- let overlapTime = record.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME;
409
- let killTime = releaseTime + overlapTime;
410
- // 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)
411
- let releaseInFlight = !!releaseTime && !!record.oldParameters && Date.now() < killTime;
412
- let isPrepTime = releaseInFlight && Date.now() >= releaseTime - RELEASE_PREP_LEAD_TIME;
413
- let isOverlapTime = releaseInFlight && Date.now() >= releaseTime;
414
- // 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
415
- let config = { ...record, parameters: releaseInFlight && record.oldParameters || getLiveServiceParameters(record) };
416
- return { record, config, releaseTime, overlapTime, killTime, releaseInFlight, isPrepTime, isOverlapTime };
417
- });
418
-
419
- // 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
420
464
  gitFastPathChecks.clear();
421
- for (let { record, config, isPrepTime } of recordStates) {
422
- const prewarm = (parameters: ServiceParameters) => {
423
- if (!parameters.gitRef) return;
424
- let targets = getMachineTargets(parameters).filter(target => target.machineId === machineId);
425
- for (let i = 0; i < targets.length; i++) {
426
- let folder = root + getScreenName({ serviceKey: parameters.key, index: i }) + "/";
427
- void checkGitSyncedAndClean(folder + "git/", parameters.gitRef);
428
- }
429
- };
430
- if (config.parameters.deploy) {
431
- prewarm(config.parameters);
432
- }
433
- if (isPrepTime && record.parameters.deploy) {
434
- prewarm(record.parameters);
435
- }
465
+ for (let desired of desiredScreens) {
466
+ if (desired.outgoing || !desired.parameters.gitRef) continue;
467
+ void checkGitSyncedAndClean(desired.folder + "git/", desired.parameters.gitRef);
436
468
  }
437
469
 
438
- // 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.
439
- await Promise.all(recordStates.map(async ({ record, config, releaseTime, overlapTime, killTime, releaseInFlight, isPrepTime, isOverlapTime }) => {
440
- if (releaseInFlight) {
441
- upcomingReleases.push({ releaseTime, overlapTime });
442
- 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()}`));
443
- }
444
-
445
- if (isPrepTime && record.parameters.deploy) {
446
- let nextTargets = getMachineTargets(record.parameters).filter(target => target.machineId === machineId);
447
- for (let i = 0; i < nextTargets.length; i++) {
448
- let next = { ...record.parameters, command: applyCommandTemplate(record.parameters.command, nextTargets[i].variables) };
449
- let canonicalScreenName = getScreenName({ serviceKey: record.parameters.key, index: i });
450
- try {
451
- if (isOverlapTime) {
452
- await ensureFutureStarted({ canonicalScreenName, next, releaseTime, serviceId: record.serviceId, index: i, machineId, screenNamesUsed, screenStateMap });
453
- } else {
454
- await ensureFuturePrepared({ canonicalScreenName, next, releaseTime, serviceId: record.serviceId, index: i, machineId, screenNamesUsed, screenStateMap });
455
- }
456
- // 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.
457
- if (!config.parameters.deploy) {
458
- let futureScreen = screenStateMap.get(getFutureScreenName(canonicalScreenName));
459
- await syncParametersTimelineFiles(root + canonicalScreenName + "/", [{
460
- pid: futureScreen?.isProcessRunning && parseInt(futureScreen.pid) || undefined,
461
- aliveWindow: [releaseTime, ALIVE_WINDOW_FOREVER],
462
- parameters: next,
463
- }]);
464
- }
465
- } catch (e: any) {
466
- console.error(`Error preparing/starting the future instance for ${record.serviceId} (${record.parameters.key}): ${e.stack}`);
467
- }
468
- }
469
- }
470
- if (!config.parameters.deploy) return;
471
- let matchedTargets = getMachineTargets(config.parameters).filter(target => target.machineId === machineId);
472
- for (let i = 0; i < matchedTargets.length; i++) {
473
- // 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.
474
- let instanceParameters = { ...config.parameters, command: applyCommandTemplate(config.parameters.command, matchedTargets[i].variables) };
475
- let screenName = getScreenName({
476
- serviceKey: config.parameters.key,
477
- index: i,
478
- });
479
-
480
- let launchCount = launchesPerService.get(config.serviceId) || 0;
481
- let lastLaunchedTime = lastLaunchedTimePerService.get(config.serviceId) || 0;
482
- machineInfo.services[config.serviceId] = {
483
- lastLaunchedTime,
484
- errorFromLastRun: "",
485
- totalTimesLaunched: launchCount,
486
- nodeId: "",
487
- };
488
- try {
489
- let folder = root + screenName + "/";
490
- screenNamesUsed.add(screenName);
491
-
492
- // Set once the future screen has become the canonical one, so the timeline stops listing it as a separate upcoming version
493
- let tookOverFuture = false;
494
-
495
- const syncTimeline = async (pid: string | undefined) => {
496
- // The windows overlap: a version runs from its releaseTime until the NEXT version's releaseTime + overlapTime (the overlap is when both run)
497
- let aliveWindow: [number, number] = [config.parameters.releaseTime || 0, releaseInFlight ? killTime : ALIVE_WINDOW_FOREVER];
498
- if (tookOverFuture) {
499
- // 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
500
- aliveWindow = [releaseTime, ALIVE_WINDOW_FOREVER];
501
- }
502
- let entries: ParametersTimelineEntry[] = [{
503
- pid: parseInt(pid || "") || undefined,
504
- aliveWindow,
505
- parameters: instanceParameters,
506
- }];
507
- if (releaseInFlight && !tookOverFuture) {
508
- let futureTargets = getMachineTargets(record.parameters).filter(target => target.machineId === machineId);
509
- if (i < futureTargets.length) {
510
- let futureScreen = screenStateMap.get(getFutureScreenName(screenName));
511
- entries.push({
512
- pid: futureScreen?.isProcessRunning && parseInt(futureScreen.pid) || undefined,
513
- aliveWindow: [releaseTime, ALIVE_WINDOW_FOREVER],
514
- parameters: { ...record.parameters, command: applyCommandTemplate(record.parameters.command, futureTargets[i].variables) },
515
- });
516
- }
517
- }
518
- await syncParametersTimelineFiles(folder, entries);
519
- };
520
-
521
- if (releaseInFlight) {
522
- // 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. Once the future instance may have started, the folder's nodeId file can belong to the NEW process, so from then on only the stashed old nodeId is safe to notify.
523
- if (!isOverlapTime) {
524
- void notifyServiceShutdown(screenName, killTime);
525
- } else {
526
- let oldNodeId = takeoverOldNodeIds.get(screenName);
527
- if (oldNodeId) {
528
- void notifyNodeShutdown(screenName, oldNodeId, killTime);
529
- }
530
- }
531
- } else {
532
- // 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
533
- await takeoverFutureScreen({ canonicalScreenName: screenName, screenNamesUsed, screenStateMap });
534
- }
535
- if (isPrepTime) {
536
- // 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
537
- if (screenStateMap.get(screenName)?.isProcessRunning) {
538
- // The old instance keeps running untouched through the overlap, but it still needs a record - it may have been launched before we restarted
539
- let panePid = screenStateMap.get(screenName)?.pid;
540
- if (panePid) {
541
- await ensureProcessRecord({
542
- screenName,
543
- folder,
544
- panePid,
545
- record: {
546
- serviceId: config.serviceId,
547
- serviceKey: config.parameters.key,
548
- index: i,
549
- machineId,
550
- parameters: instanceParameters,
551
- },
552
- });
553
- }
554
- await syncTimeline(screenStateMap.get(screenName)?.pid);
555
- let nodePathId = folder + SERVICE_NODE_FILE_NAME;
556
- if (await fsExistsAsync(nodePathId)) {
557
- machineInfo.services[config.serviceId].nodeId = await fs.promises.readFile(nodePathId, "utf8");
558
- }
559
- console.log(green(`Verified ${magenta(screenName)} is running (release in flight, leaving it untouched until ${new Date(killTime).toLocaleString()})`));
560
- continue;
561
- }
562
- // The old instance is dead, so there is nothing to keep alive through the overlap — take the new version over immediately
563
- console.log(magenta(`Old instance ${screenName} is not running during a release, taking the new version over immediately`));
564
- await takeoverFutureScreen({ canonicalScreenName: screenName, screenNamesUsed, screenStateMap });
565
- tookOverFuture = true;
566
- let nextTargets = getMachineTargets(record.parameters).filter(target => target.machineId === machineId);
567
- if (record.parameters.deploy && record.parameters.key === config.parameters.key && i < nextTargets.length) {
568
- instanceParameters = { ...record.parameters, command: applyCommandTemplate(record.parameters.command, nextTargets[i].variables) };
569
- }
570
- }
571
-
572
- let gitFolder = folder + "git/";
573
- await fs.promises.mkdir(gitFolder, { recursive: true });
574
- if (instanceParameters.gitRef) {
575
- let prevGitRef = "";
576
- try {
577
- prevGitRef = await getGitRefLive(gitFolder);
578
- } catch { }
579
- await ensureGitSynced({
580
- gitFolder: gitFolder,
581
- repoUrl: instanceParameters.repoUrl,
582
- gitRef: instanceParameters.gitRef,
583
- });
584
- let afterGitRef = await getGitRefLive(gitFolder);
585
- // 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.
586
- let nodeModulesMissing = !await fsExistsAsync(gitFolder + "node_modules");
587
- if (afterGitRef !== prevGitRef || nodeModulesMissing) {
588
- console.log(green(`Yarn installing for ${magenta(screenName)} (ref ${prevGitRef} -> ${afterGitRef}, nodeModulesMissing=${nodeModulesMissing})`));
589
- await runPromise(`yarn install --mutex network`, { cwd: gitFolder });
590
- }
591
- }
592
- let parameterPath = folder + "/parameters.json";
593
- let prevParameters = "";
594
- if (await fsExistsAsync(parameterPath)) {
595
- prevParameters = await fs.promises.readFile(parameterPath, "utf8");
596
- }
597
- let newParametersString = JSON.stringify(instanceParameters);
598
-
599
- let sameParameters = sameRestartParameters(prevParameters, instanceParameters);
600
- let screenIsRunning = screenStateMap.get(screenName)?.isProcessRunning;
601
-
602
- let nodePathId = folder + SERVICE_NODE_FILE_NAME;
603
- if (await fsExistsAsync(nodePathId)) {
604
- let nodeId = await fs.promises.readFile(nodePathId, "utf8");
605
- machineInfo.services[config.serviceId].nodeId = nodeId;
606
- }
607
-
608
- if (sameParameters && screenIsRunning) {
609
- if (prevParameters !== newParametersString) {
610
- await fs.promises.writeFile(parameterPath, newParametersString);
611
- }
612
- // We did not launch this process (we restarted, or it predates process records), so make sure it is recorded before we leave it alone
613
- let panePid = screenStateMap.get(screenName)?.pid;
614
- if (panePid) {
615
- await ensureProcessRecord({
616
- screenName,
617
- folder,
618
- panePid,
619
- record: {
620
- serviceId: config.serviceId,
621
- serviceKey: config.parameters.key,
622
- index: i,
623
- machineId,
624
- parameters: instanceParameters,
625
- },
626
- });
627
- }
628
- await syncTimeline(screenStateMap.get(screenName)?.pid);
629
- console.log(green(`Verified ${magenta(screenName)} is running`));
630
- continue;
631
- }
632
-
633
- console.log(`Resyncing service ${magenta(screenName)}, with ${newParametersString}, isRunning = ${screenIsRunning}, sameParameters = ${sameParameters}`);
634
-
635
- await fs.promises.writeFile(parameterPath, newParametersString);
636
-
637
- let launchReason: "crashed" | "update";
638
- if (!sameParameters) {
639
- launchReason = "update";
640
- } else {
641
- launchReason = "crashed";
642
- }
643
- void recordLaunch({
644
- serviceId: config.serviceId,
645
- serviceKey: config.parameters.key,
646
- screenName,
647
- machineId,
648
- reason: launchReason,
649
- time: Date.now(),
650
- });
651
-
652
- // 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.
653
- let launchScreenName = getFutureScreenName(screenName);
654
- let launchId = await runScreenCommand({
655
- screenName: launchScreenName,
656
- folder,
657
- command: instanceParameters.command,
658
- record: {
659
- serviceId: config.serviceId,
660
- serviceKey: config.parameters.key,
661
- index: i,
662
- machineId,
663
- parameters: instanceParameters,
664
- },
665
- });
666
- await delay(2000);
667
- let newScreens = await getScreenState(false);
668
- let foundScreen = newScreens.find(x => x.screenName === launchScreenName);
669
- if (!foundScreen) {
670
- console.error(`Just created screen ${launchScreenName}, but it's not in the list of screens!`);
671
- }
672
- let isRunning = foundScreen && await isScreenRunningProcess(foundScreen.pid);
673
- if (!isRunning) {
674
- let prefix = getTmuxPrefix();
675
- let logs = await runPromise(`${prefix}tmux capture-pane -t ${launchScreenName} -p`);
676
- 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.`);
677
- }
678
- // Up and verified, so the old process can go now
679
- if (foundScreen) {
680
- screenStateMap.set(launchScreenName, foundScreen);
681
- }
682
- await takeoverFutureScreen({ canonicalScreenName: screenName, screenNamesUsed, screenStateMap });
683
- console.log(green(`Service ${magenta(screenName)} is verified to be running after starting (process ${launchId})`));
684
- await syncTimeline(foundScreen?.pid);
685
-
686
- launchCount++;
687
- lastLaunchedTime = Date.now();
688
- } catch (e: any) {
689
- // 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.
690
- launchCount++;
691
- lastLaunchedTime = Date.now();
692
- console.error(`Error resyncing service ${magenta(config.serviceId)} (${config.parameters?.key}): ${e.stack}`);
693
- machineInfo.services[config.serviceId].errorFromLastRun = e.stack;
694
- }
695
- machineInfo.services[config.serviceId].totalTimesLaunched = launchCount;
696
- machineInfo.services[config.serviceId].lastLaunchedTime = lastLaunchedTime;
697
- lastLaunchedTimePerService.set(config.serviceId, lastLaunchedTime);
698
- launchesPerService.set(config.serviceId, launchCount);
699
- }
700
- }));
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 })));
701
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
+ }
702
479
  for (let { screenName } of screenState) {
703
- 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;
704
483
  // Detached, so a screen that takes its full grace period to go down doesn't hold up the end of the resync
705
- void killScreen({
706
- screenName,
707
- });
484
+ void (async () => {
485
+ // Nothing is replacing this one, so its registration and the file it wrote both go
486
+ let folder = root + screenName + "/";
487
+ let nodeId = (await findRunningRecord(folder, screenName))?.nodeId || await readServiceNodeId(folder);
488
+ if (nodeId) {
489
+ await removeServiceNode({ folder, nodeId, ownsFile: true });
490
+ }
491
+ await killScreen({ screenName });
492
+ })();
708
493
  }
709
494
 
710
- 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);
711
497
 
712
498
  await machineInfos.set(machineId, machineInfo);
713
499
  console.log(`${magenta(`Resynced ${relevantConfigs.length} services`)}:\n${JSON.stringify(machineInfo, null, 2)}`);
@@ -723,17 +509,16 @@ async function resyncServices() {
723
509
  }
724
510
  }
725
511
 
726
- // 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.
727
513
  let cancelReleaseResync: (() => void) | undefined;
728
- function scheduleReleaseResync(upcomingReleases: { releaseTime: number; overlapTime: number }[]) {
514
+ function scheduleReleaseResync(desiredScreens: DesiredScreen[]) {
729
515
  let now = Date.now();
730
516
  let next: number | undefined;
731
- for (let release of upcomingReleases) {
732
- for (let boundary of [release.releaseTime - RELEASE_PREP_LEAD_TIME, release.releaseTime, release.releaseTime + release.overlapTime]) {
733
- if (boundary <= now) continue;
734
- if (next === undefined || boundary < next) {
735
- next = boundary;
736
- }
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;
737
522
  }
738
523
  }
739
524
  if (cancelReleaseResync) {
@@ -30,6 +30,8 @@ export type ProcessRecord = {
30
30
  machineId: string;
31
31
  /** The start time the OS reports for the pid, which is also half of the launchId */
32
32
  startTime: number;
33
+ /** The node this process registered as, read out of the folder before any replacement is started - after that the file belongs to the replacement. Kept here so taking this process down needs nothing but its own record. */
34
+ nodeId?: string;
33
35
  /** When we first noticed the process was gone. Absent while it is running. */
34
36
  deadTime?: number;
35
37
  pid?: number;
@@ -99,6 +101,13 @@ while true; do
99
101
  done`;
100
102
  }
101
103
 
104
+ /** The record of whatever process is currently running in a screen, if we have one. */
105
+ export async function findRunningRecord(folder: string, screenName: string): Promise<ProcessRecord | undefined> {
106
+ return (await listProcessRecords()).find(x =>
107
+ x.folder === folder && x.screenName === screenName && x.deadTime === undefined
108
+ );
109
+ }
110
+
102
111
  export async function writeProcessRecord(record: ProcessRecord): Promise<void> {
103
112
  await fs.promises.mkdir(path.join(record.folder, PROCESS_FOLDER), { recursive: true });
104
113
  await fs.promises.writeFile(getRecordPath(record.folder, record.launchId), JSON.stringify(record));
@@ -13,7 +13,8 @@ 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, createLaunchId, getLogPipeScript, getLogTailScript, getProcessLogPath, getPipeScriptPath, getTailScriptPath, listProcessRecords, writeProcessRecord } from "./processLogs";
16
+ import { ProcessRecord, createLaunchId, getLogPipeScript, getLogTailScript, getProcessLogPath, getPipeScriptPath, getTailScriptPath, listProcessRecords, findRunningRecord, writeProcessRecord } from "./processLogs";
17
+ import { setPreciseTimeout } from "../misc";
17
18
 
18
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.
19
20
 
@@ -24,9 +25,11 @@ const SCREEN_SUFFIX = "-dply";
24
25
  export function getScreenName(config: { serviceKey: string; index: number }): string {
25
26
  return `${config.serviceKey}-${config.index}${SCREEN_SUFFIX}`.replace(/[^a-zA-Z0-9\-_]/g, "_");
26
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-";
27
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. */
28
31
  export function getRetiringScreenName(canonicalScreenName: string, at: number): string {
29
- return canonicalScreenName.slice(0, -SCREEN_SUFFIX.length) + `-retiring-${at}` + SCREEN_SUFFIX;
32
+ return canonicalScreenName.slice(0, -SCREEN_SUFFIX.length) + RETIRING_MARKER + at + SCREEN_SUFFIX;
30
33
  }
31
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.
32
35
  export function getFutureScreenName(canonicalScreenName: string): string {
@@ -34,16 +37,47 @@ export function getFutureScreenName(canonicalScreenName: string): string {
34
37
  }
35
38
 
36
39
 
37
- async function removeOldNodeId(screenName: string) {
38
- let nodeIdFile = os.homedir() + "/" + SERVICE_FOLDER + screenName + "/" + SERVICE_NODE_FILE_NAME;
39
- if (await fsExistsAsync(nodeIdFile)) {
40
- let nodeId = await fs.promises.readFile(nodeIdFile, "utf8");
41
- console.log(green(`Removing node if for dead service on ${nodeIdFile}, node id ${nodeId}`));
42
- await fs.promises.unlink(nodeIdFile);
43
- await forceRemoveNode(nodeId);
40
+ // Everything that touches a service's screens - the resync loop, and the timer that retires the old process at its kill time - runs under this, keyed by the canonical screen name. They fire at similar times by design, and a takeover is a rename dance that must not interleave with another one.
41
+ const screenLocks = new Map<string, Promise<unknown>>();
42
+ export function withScreenLock<T>(canonicalScreenName: string, run: () => Promise<T>): Promise<T> {
43
+ let previous = screenLocks.get(canonicalScreenName) || Promise.resolve();
44
+ let next = previous.then(run, run);
45
+ // Failures release the lock rather than poisoning every later operation on this screen
46
+ screenLocks.set(canonicalScreenName, next.then(() => { }, () => { }));
47
+ return next;
48
+ }
49
+
50
+ /** Whether a session exists, without listing every session and its panes - the takeover already knows the exact names it cares about. */
51
+ async function screenExists(screenName: string): Promise<boolean> {
52
+ let prefix = getTmuxPrefix();
53
+ try {
54
+ await runPromise(`${prefix}tmux has-session -t ${screenName}`, { quiet: true });
55
+ return true;
56
+ } catch {
57
+ // has-session exits non-zero for "no such session", which is an answer, not a failure
58
+ return false;
44
59
  }
45
60
  }
46
61
 
62
+ /** The node the process in this folder registered as. Read it BEFORE starting a replacement in the same folder - once the replacement starts, the file is the replacement's. */
63
+ export async function readServiceNodeId(folder: string): Promise<string | undefined> {
64
+ let nodeIdFile = path.join(folder, SERVICE_NODE_FILE_NAME);
65
+ if (!await fsExistsAsync(nodeIdFile)) return undefined;
66
+ return await fs.promises.readFile(nodeIdFile, "utf8");
67
+ }
68
+
69
+ /** Drops a process's node registration. The file is only removed when it is still that process's - a replacement has its own registration in the same file. */
70
+ export async function removeServiceNode(config: { folder: string; nodeId: string; ownsFile: boolean }): Promise<void> {
71
+ console.log(green(`Removing the node registration ${config.nodeId} for the process in ${config.folder}`));
72
+ if (config.ownsFile) {
73
+ let nodeIdFile = path.join(config.folder, SERVICE_NODE_FILE_NAME);
74
+ if (await fsExistsAsync(nodeIdFile)) {
75
+ await fs.promises.unlink(nodeIdFile);
76
+ }
77
+ }
78
+ await forceRemoveNode(config.nodeId);
79
+ }
80
+
47
81
  export const getTmuxPrefix = lazy(() => {
48
82
  if (os.platform() === "win32") {
49
83
  return "C:/cygwin64/bin/";
@@ -183,6 +217,8 @@ export const runScreenCommand = measureWrap(async function runScreenCommand(conf
183
217
  folder?: string;
184
218
  // Identifies the process this launch creates, so its log and its configuration are stored against it
185
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 };
186
222
  }): Promise<string> {
187
223
  let prefix = getTmuxPrefix();
188
224
  let screenName = config.screenName;
@@ -195,9 +231,17 @@ export const runScreenCommand = measureWrap(async function runScreenCommand(conf
195
231
  }
196
232
  await runPromise(`${prefix}tmux new -s ${screenName} -d`);
197
233
 
198
- await removeOldNodeId(screenName);
199
234
  let folder = config.folder || os.homedir() + "/" + SERVICE_FOLDER + screenName + "/";
200
235
 
236
+ // Read the outgoing process's node id onto its own record BEFORE this launch overwrites the file. Whoever takes that process down then has everything it needs on the record, instead of us having to remember not to touch a file that stopped being its own.
237
+ let outgoingNodeId = await readServiceNodeId(folder);
238
+ if (outgoingNodeId) {
239
+ for (let record of await listProcessRecords()) {
240
+ if (record.folder !== folder || record.deadTime !== undefined || record.nodeId) continue;
241
+ await writeProcessRecord({ ...record, nodeId: outgoingNodeId });
242
+ }
243
+ }
244
+
201
245
  // The pane we are about to run the command in identifies the process: its pid, and the start time the OS reports for that pid. Both can be re-checked against the machine later, so nothing downstream has to trust our bookkeeping about what is still running. The session was just created, so this pid is always new.
202
246
  let panePid = (await getScreenState(false)).find(x => x.screenName === screenName)?.pid;
203
247
  if (!panePid) {
@@ -219,6 +263,14 @@ ${config.command}
219
263
  `;
220
264
  await fs.promises.writeFile(folder + "command.sh", command);
221
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
+ }
222
274
 
223
275
  await setupPipePane({ screenName, folder, launchId });
224
276
  return launchId;
@@ -267,10 +319,60 @@ async function setupPipePane(config: { screenName: string; folder: string; launc
267
319
  await runPromise(`${prefix}tmux pipe-pane -t ${config.screenName} 'bash ${pipeScript}'`);
268
320
  }
269
321
 
322
+ /** Retires whatever is running under the canonical name and renames the future screen onto it. Everything it needs is derived from the two names, so it costs two `has-session` checks - the kill time is when this runs, not when a resync gets around to it. Returns whether there was a future screen to take over. */
323
+ export async function takeoverScreen(config: { canonicalScreenName: string; folder: string }): Promise<boolean> {
324
+ let { canonicalScreenName, folder } = config;
325
+ return await withScreenLock(canonicalScreenName, async () => {
326
+ let futureScreenName = getFutureScreenName(canonicalScreenName);
327
+ if (!await screenExists(futureScreenName)) return false;
328
+ let prefix = getTmuxPrefix();
329
+ if (await screenExists(canonicalScreenName)) {
330
+ let retiringScreenName = getRetiringScreenName(canonicalScreenName, Date.now());
331
+ // Its record carries the node it registered as, taken before the replacement started - the file in the folder belongs to the replacement now, so it is left alone
332
+ let outgoing = await findRunningRecord(folder, canonicalScreenName);
333
+ await runPromise(`${prefix}tmux rename-session -t ${canonicalScreenName} ${retiringScreenName}`);
334
+ if (outgoing) {
335
+ await writeProcessRecord({ ...outgoing, screenName: retiringScreenName });
336
+ if (outgoing.nodeId) {
337
+ void removeServiceNode({ folder, nodeId: outgoing.nodeId, ownsFile: false });
338
+ }
339
+ }
340
+ // Detached: the canonical name is already free, so nothing waits on the old process winding down
341
+ void killScreen({ screenName: retiringScreenName });
342
+ }
343
+ console.log(green(`Renaming future screen ${futureScreenName} to ${canonicalScreenName} (takeover complete)`));
344
+ await runPromise(`${prefix}tmux rename-session -t ${futureScreenName} ${canonicalScreenName}`);
345
+ let incoming = await findRunningRecord(folder, futureScreenName);
346
+ if (incoming) {
347
+ await writeProcessRecord({ ...incoming, screenName: canonicalScreenName });
348
+ }
349
+ return true;
350
+ });
351
+ }
352
+
353
+ // canonical screen name -> the takeover we already have scheduled for it
354
+ const scheduledTakeovers = new Map<string, { time: number; cancel: () => void }>();
355
+
356
+ /** Schedules the takeover for the moment the old process's overlap runs out. The resync loop still does the same takeover whenever it notices the time has passed, which is what recovers this if we restart and lose the timer. */
357
+ export function scheduleTakeover(config: { canonicalScreenName: string; folder: string; killTime: number }): void {
358
+ let { canonicalScreenName, folder, killTime } = config;
359
+ let existing = scheduledTakeovers.get(canonicalScreenName);
360
+ if (existing?.time === killTime) return;
361
+ existing?.cancel();
362
+ console.log(green(`Scheduling the takeover of ${canonicalScreenName} for ${new Date(killTime).toISOString()}`));
363
+ let cancel = setPreciseTimeout({
364
+ time: killTime,
365
+ callback: () => {
366
+ scheduledTakeovers.delete(canonicalScreenName);
367
+ void takeoverScreen({ canonicalScreenName, folder });
368
+ },
369
+ });
370
+ scheduledTakeovers.set(canonicalScreenName, { time: killTime, cancel });
371
+ }
372
+
373
+ // 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.
270
374
  export const killScreen = measureWrap(async function killScreen(config: {
271
375
  screenName: string;
272
- // During a takeover the folder's nodeId file already belongs to the NEW process, so the old screen's kill must not remove it
273
- skipNodeIdRemoval?: boolean;
274
376
  }) {
275
377
  console.log(red(`Killing screen ${config.screenName} (Ctrl+C, then killing the session in ${formatTime(SHUTDOWN_GRACE_TIME)})`));
276
378
  let prefix = getTmuxPrefix();
@@ -278,9 +380,6 @@ export const killScreen = measureWrap(async function killScreen(config: {
278
380
  await runPromise(`${prefix}tmux send-keys -t ${config.screenName} 'C-c' Enter`);
279
381
  await delay(SHUTDOWN_GRACE_TIME);
280
382
  await runPromise(`${prefix}tmux kill-session -t ${config.screenName}`);
281
- if (!config.skipNodeIdRemoval) {
282
- await removeOldNodeId(config.screenName);
283
- }
284
383
  });
285
384
 
286
385
  /** Streams one process's log: everything already in it, then everything appended after. One process, one file - nothing here has to reason about which process a byte came from. */