querysub 0.570.0 → 0.572.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/appSecrets.ts CHANGED
@@ -55,6 +55,7 @@ let doImmediateShutdown = lazy(() => {
55
55
  });
56
56
  }
57
57
  function doShutdown() {
58
+ console.log("SIGINT received, shutting down immediately");
58
59
  process.exit();
59
60
  }
60
61
  process.on("SIGINT", doShutdown);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.570.0",
3
+ "version": "0.572.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -32,14 +32,15 @@ class ProcessOutput extends qreact.Component<{ record: ProcessRecord; machineNod
32
32
  private unmounted = false;
33
33
  componentDidMount() {
34
34
  // Props are synchronized state, so the plain values the async work needs are read here
35
- let { folder, launchId } = this.props.record;
35
+ let { folder, pid, startTime } = this.props.record;
36
36
  let nodeId = this.props.machineNodeId;
37
37
  let callbackId = this.callbackId;
38
38
  this.watching = (async () => {
39
39
  await watchProcessOutput({
40
40
  nodeId,
41
41
  folder,
42
- launchId,
42
+ pid,
43
+ startTime,
43
44
  callbackId,
44
45
  onData: async (data: string) => {
45
46
  Querysub.commit(() => {
@@ -153,9 +154,9 @@ class MachineProcesses extends qreact.Component<{ machineId: string; applyNodeId
153
154
  {this.state.showHistorical && `Hide ${historical.length} historical` || `Show ${historical.length} historical`}
154
155
  </Button>}
155
156
  </div>
156
- {running.map(record => <ProcessRow key={record.launchId} record={record} machineNodeId={this.props.applyNodeId} />)}
157
+ {running.map(record => <ProcessRow key={`${record.pid}-${record.startTime}`} record={record} machineNodeId={this.props.applyNodeId} />)}
157
158
  {this.state.showHistorical && historical.map(record =>
158
- <ProcessRow key={record.launchId} record={record} machineNodeId={this.props.applyNodeId} />
159
+ <ProcessRow key={`${record.pid}-${record.startTime}`} record={record} machineNodeId={this.props.applyNodeId} />
159
160
  )}
160
161
  </div>;
161
162
  }
@@ -10,7 +10,7 @@ import { getExternalIP } from "socket-function/src/networking";
10
10
  import { errorToUndefined, errorToUndefinedSilent } from "../errors";
11
11
  import { getDomain } from "../config";
12
12
  import { formatTime } from "socket-function/src/formatting/format";
13
- import { sort, timeInMinute, timeInSecond } from "socket-function/src/misc";
13
+ import { sort, timeInMinute, timeInSecond, watchSlowPromise } from "socket-function/src/misc";
14
14
  import { isDefined, setPreciseTimeout } from "../misc";
15
15
  import { logLoadTime } from "../logModuleLoadTimes";
16
16
  import { delay, retryFunctional, runInSerial, runInfinitePoll, runInfinitePollCallAtStart } from "socket-function/src/batching";
@@ -26,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, 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";
29
+ import { getScreenName, getFutureScreenName, isRetiring, getTmuxPrefix, getScreenState, isScreenRunningProcess, getProcessStartTime, getScreenPanePid, ensureProcessRecord, runScreenCommand, killScreen, readServiceNodeId, removeServiceNode, takeoverScreenLocked, scheduleTakeover, killScreenNow, withScreenLock } from "./processManager";
30
+ import { ProcessRecord, syncProcessRecords, listProcessRecords } from "./processLogs";
31
31
 
32
32
 
33
33
 
@@ -222,87 +222,65 @@ let lastLaunchedTimePerService = new Map<string, number>();
222
222
 
223
223
  const LAUNCH_VERIFY_DELAY = 2000;
224
224
 
225
- /** One tmux session that should exist right now, and the configuration it should be running. The whole resync is derived from this list: converge each of these, kill everything else. */
226
- type DesiredScreen = {
227
- /** The session that should exist. For an incoming version that is the "-future" name, until it takes the canonical name over. */
228
- screenName: string;
229
- /** The slot this belongs to - its own name, or the one it is replacing */
230
- canonicalScreenName: string;
231
- /** The instance folder, which is always the canonical screen's folder (an incoming version runs out of the same one) */
232
- folder: string;
233
- serviceId: string;
234
- serviceKey: string;
235
- index: number;
236
- machineId: string;
237
- /** The parameters it runs, with template variables already resolved */
238
- parameters: ServiceParameters;
239
- aliveWindow: [number, number];
240
- /** The version being replaced during an overlap: it must keep running and must not be touched, because the folder already belongs to its replacement. */
241
- outgoing?: boolean;
242
- /** On an incoming version, when it takes the canonical name over. Absent means take over as soon as it is up. */
243
- takeoverAt?: number;
244
- };
245
-
246
- /** Every screen that should exist on this machine, worked out purely from the configs and the clock. */
247
- function getDesiredScreens(configs: ServiceConfig[], machineId: string, root: string): DesiredScreen[] {
248
- let desired: DesiredScreen[] = [];
249
- let now = Date.now();
225
+ /** Every instance of every service that belongs to this machine, with its config already resolved for that instance - the command has this entry's template variables substituted, and a version with no entry at this index is marked as deploying nothing here. */
226
+ function getMachineInstances(configs: ServiceConfig[], machineId: string): { config: ServiceConfig; index: number }[] {
227
+ let instances: { config: ServiceConfig; index: number }[] = [];
250
228
  for (let config of configs) {
251
- let releaseTime = config.parameters.releaseTime || 0;
252
- // The overlap length always comes from the NEWEST parameters, even though it governs how long the OLD instances outlive the release
253
- let overlapTime = config.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME;
254
- let killTime = releaseTime + overlapTime;
255
- // Only in flight while there is an old version to keep alive, and only until its overlap runs out
256
- let releaseInFlight = !!releaseTime && !!config.oldParameters && now < killTime;
257
-
258
- const add = (parameters: ServiceParameters, extra: Partial<DesiredScreen>) => {
259
- if (!parameters.deploy) return;
260
- let targets = getMachineTargets(parameters).filter(target => target.machineId === machineId);
261
- for (let index = 0; index < targets.length; index++) {
262
- let canonicalScreenName = getScreenName({ serviceKey: parameters.key, index });
263
- desired.push({
264
- screenName: canonicalScreenName,
265
- canonicalScreenName,
266
- folder: root + canonicalScreenName + "/",
267
- serviceId: config.serviceId,
268
- serviceKey: parameters.key,
269
- index,
270
- machineId,
271
- // Each instance runs the command with its own entry's variables resolved, so the parameters written to disk and compared for restarts carry the resolved command
272
- parameters: { ...parameters, command: applyCommandTemplate(parameters.command, targets[index].variables) },
273
- aliveWindow: [parameters.releaseTime || 0, ALIVE_WINDOW_FOREVER],
274
- ...extra,
275
- });
276
- }
277
- };
278
-
279
- if (releaseInFlight && config.oldParameters) {
280
- // The old version keeps running under the canonical name until its overlap runs out
281
- add(config.oldParameters, { outgoing: true, aliveWindow: [config.oldParameters.releaseTime || 0, killTime] });
282
- // The new version runs alongside it from the release, and takes the canonical name at killTime
283
- if (now >= releaseTime) {
284
- add(config.parameters, { takeoverAt: killTime, aliveWindow: [releaseTime, ALIVE_WINDOW_FOREVER] });
285
- }
286
- } else {
287
- add(getLiveServiceParameters(config), {});
229
+ // A release can add or remove instances, so the count is whichever version asks for the most
230
+ let count = 0;
231
+ for (let parameters of [config.parameters, config.oldParameters]) {
232
+ if (!parameters) continue;
233
+ count = Math.max(count, getMachineTargets(parameters).filter(x => x.machineId === machineId).length);
234
+ }
235
+ for (let index = 0; index < count; index++) {
236
+ const resolve = (parameters: ServiceParameters) => {
237
+ let target = getMachineTargets(parameters).filter(x => x.machineId === machineId)[index];
238
+ return {
239
+ ...parameters,
240
+ command: target && applyCommandTemplate(parameters.command, target.variables) || parameters.command,
241
+ deploy: !!parameters.deploy && !!target,
242
+ };
243
+ };
244
+ instances.push({
245
+ index,
246
+ config: {
247
+ ...config,
248
+ parameters: resolve(config.parameters),
249
+ oldParameters: config.oldParameters && resolve(config.oldParameters),
250
+ },
251
+ });
288
252
  }
289
253
  }
290
- // An incoming version lives under the future name until it takes over
291
- for (let screen of desired) {
292
- if (screen.takeoverAt === undefined) continue;
293
- screen.screenName = getFutureScreenName(screen.canonicalScreenName);
294
- }
295
- return desired;
254
+ return instances;
296
255
  }
297
256
 
298
- /** Puts one screen into the state its configuration describes: the folder synced, the process running what it should be, and the records and timeline saying so. Everything about one screen happens under its lock, so a resync and a scheduled takeover can never interleave. */
257
+ /** Puts one instance of a service into the state its configuration describes: the folder synced, the process running what it should be, and the records and timeline saying so. Returns the screens it is using, so anything nobody claimed can be killed. Everything about one screen happens under its lock, so a resync and a scheduled takeover can never interleave. */
299
258
  async function ensureScreen(config: {
300
- desired: DesiredScreen;
301
- screenStateMap: Map<string, { screenName: string; isProcessRunning: boolean; pid: string }>;
259
+ config: ServiceConfig;
260
+ index: number;
302
261
  machineInfo: MachineInfo;
303
- }): Promise<void> {
304
- let { desired, screenStateMap, machineInfo } = config;
305
- let { screenName, canonicalScreenName, folder, parameters, serviceId } = desired;
262
+ }): Promise<string[]> {
263
+ let { machineInfo, index } = config;
264
+ let machineId = machineInfo.machineId;
265
+ let serviceId = config.config.serviceId;
266
+ let serviceConfig = config.config;
267
+
268
+ let releaseTime = serviceConfig.parameters.releaseTime || 0;
269
+ // The overlap length always comes from the NEWEST parameters, even though it governs how long the OLD instances outlive the release
270
+ let killTime = releaseTime + (serviceConfig.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME);
271
+ // Only in flight while there is an old version to keep alive, and only until its overlap runs out
272
+ let releaseInFlight = !!releaseTime && !!serviceConfig.oldParameters && Date.now() < killTime;
273
+
274
+ // What should be running under the canonical name now, and the version waiting to take it over
275
+ let current: ServiceParameters | undefined = releaseInFlight && serviceConfig.oldParameters || getLiveServiceParameters(serviceConfig);
276
+ if (!current?.deploy) current = undefined;
277
+ let incoming: ServiceParameters | undefined = releaseInFlight && Date.now() >= releaseTime && serviceConfig.parameters || undefined;
278
+ if (!incoming?.deploy) incoming = undefined;
279
+ let canonicalScreenName = getScreenName({ serviceKey: (current || incoming || serviceConfig.parameters).key, index });
280
+ let futureScreenName = getFutureScreenName(canonicalScreenName);
281
+ let folder = os.homedir() + "/" + SERVICE_FOLDER + canonicalScreenName + "/";
282
+ // Returned even when the work below throws, so a transient failure never makes the sweep kill a screen we still want
283
+ let usedScreenNames = [canonicalScreenName, futureScreenName];
306
284
 
307
285
  let launchCount = launchesPerService.get(serviceId) || 0;
308
286
  let lastLaunchedTime = lastLaunchedTimePerService.get(serviceId) || 0;
@@ -314,46 +292,70 @@ async function ensureScreen(config: {
314
292
  };
315
293
 
316
294
  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;
295
+ await watchSlowPromise(`ensureScreen|${canonicalScreenName}`, withScreenLock(canonicalScreenName, async () => {
296
+ /* Three facts, each moving ? -> known -> correct as we go:
297
+ EXISTS - which of the two screens are there
298
+ VERSION - which parameters they were started with
299
+ RUNNING - whether a process is actually alive in them
300
+ Every step below says where those three stand, so it is always clear what we are still assuming.
301
+ START: EXISTS ? VERSION ? RUNNING ?
302
+ */
303
+ let parameters = incoming || current;
304
+ let screenName = incoming && futureScreenName || canonicalScreenName;
305
+ let takeoverAt = incoming && killTime || undefined;
306
+
307
+ // A screen we do not expect holds something we never asked for, so it goes at once - nothing here is worth winding down. Each name is judged on its own: a release that turns deploy on has an incoming version and nothing that should be running under the canonical name yet.
308
+ // AFTER: EXISTS correct in the "nothing extra" sense. A screen that SHOULD exist may still be missing, which is RUNNING's problem below.
309
+ if (!incoming) {
310
+ await killScreenNow(futureScreenName, "there is no release in flight, so nothing should be waiting to take over");
333
311
  }
334
-
335
- let gitFolder = folder + "git/";
336
- await fs.promises.mkdir(gitFolder, { recursive: true });
337
- if (parameters.gitRef) {
338
- let prevGitRef = "";
339
- try {
340
- prevGitRef = await getGitRefLive(gitFolder);
341
- } catch { }
342
- await ensureGitSynced({ gitFolder, repoUrl: parameters.repoUrl, gitRef: parameters.gitRef });
343
- let afterGitRef = await getGitRefLive(gitFolder);
344
- // Reinstall when the ref changed OR node_modules is missing. A recovery re-clone can land on the same commit, so a ref-only check would leave the service with no node_modules.
345
- let nodeModulesMissing = !await fsExistsAsync(gitFolder + "node_modules");
346
- if (afterGitRef !== prevGitRef || nodeModulesMissing) {
347
- console.log(green(`Yarn installing for ${magenta(screenName)} (ref ${prevGitRef} -> ${afterGitRef}, nodeModulesMissing=${nodeModulesMissing})`));
348
- await runPromise(`yarn install --mutex network`, { cwd: gitFolder });
349
- }
312
+ if (!current) {
313
+ await killScreenNow(canonicalScreenName, "nothing is supposed to be serving this name yet");
350
314
  }
315
+ if (!current && !incoming) return;
351
316
 
317
+ // Both screens run out of one folder, so its parameters.json is the ONE version they were last started from - it cannot tell the two screens apart.
318
+ // AFTER: VERSION known. Anything it does not match, including a version we no longer recognize, is handled by starting the right one further down - which keeps whatever is up serving until the replacement is verified, instead of killing it now and serving nothing while we sync and start.
352
319
  let parameterPath = folder + "parameters.json";
353
320
  let prevParameters = "";
354
321
  if (await fsExistsAsync(parameterPath)) {
355
322
  prevParameters = await fs.promises.readFile(parameterPath, "utf8");
356
323
  }
324
+
325
+ // AFTER: RUNNING known for every name that can still have anything in it. A screen can exist with nothing alive in it, and one that should exist can be missing entirely - both read as not up, and both are fixed the same way. Without an incoming version the future name was just killed, so there is nothing left to ask about it.
326
+ let canonicalPid = await getScreenPanePid(canonicalScreenName);
327
+ let canonicalUp = !!canonicalPid && await isScreenRunningProcess(canonicalPid);
328
+ let futurePid = incoming && await getScreenPanePid(futureScreenName) || undefined;
329
+ let futureUp = !!futurePid && await isScreenRunningProcess(futurePid);
330
+
331
+ // SMOOTH PATH: this is what a deploy looks like from the canonical name's side. The version being replaced is already correct - it keeps running untouched until its overlap ends, so this name is settled and only the future one is left to converge. It needs its record kept straight, and to hear when it goes down.
332
+ // AFTER (canonical, during a release): EXISTS correct VERSION correct RUNNING correct
333
+ if (incoming && current && !canonicalUp) {
334
+ console.log(red(`Nothing is running as ${canonicalScreenName}, which should be serving until ${new Date(killTime).toISOString()} while its replacement starts`));
335
+ }
336
+ if (incoming && current && canonicalUp && canonicalPid) {
337
+ await ensureProcessRecord({
338
+ screenName: canonicalScreenName,
339
+ folder,
340
+ panePid: canonicalPid,
341
+ record: { serviceId, serviceKey: current.key, index, machineId, parameters: current },
342
+ });
343
+ void (async () => {
344
+ let nodeId = await readServiceNodeId(folder);
345
+ if (nodeId) {
346
+ await notifyNodeShutdown(canonicalScreenName, nodeId, killTime);
347
+ }
348
+ })();
349
+ }
350
+
351
+ // From here on this is about ONE screen - the future one during a release, the canonical one otherwise. Everything else is already correct.
352
+ // STATE: EXISTS correct VERSION known RUNNING known
353
+ if (!parameters) return;
354
+ let panePid = incoming && futurePid || canonicalPid;
355
+ let isUp = incoming && futureUp || canonicalUp;
356
+ let aliveWindow: [number, number] = [parameters.releaseTime || 0, ALIVE_WINDOW_FOREVER];
357
+ let record = { serviceId, serviceKey: parameters.key, index, machineId, parameters };
358
+
357
359
  let newParametersString = JSON.stringify(parameters);
358
360
 
359
361
  let nodePathId = folder + SERVICE_NODE_FILE_NAME;
@@ -361,69 +363,112 @@ async function ensureScreen(config: {
361
363
  machineInfo.services[serviceId].nodeId = await fs.promises.readFile(nodePathId, "utf8");
362
364
  }
363
365
 
364
- if (sameRestartParameters(prevParameters, parameters) && screen?.isProcessRunning) {
366
+ // Already running this exact version, so nothing needs starting - this is the state we are trying to reach
367
+ // AFTER: EXISTS correct VERSION correct RUNNING correct, and the takeover below settles which name it answers to
368
+ if (isUp && panePid && sameRestartParameters(prevParameters, parameters)) {
365
369
  if (prevParameters !== newParametersString) {
366
370
  await fs.promises.writeFile(parameterPath, newParametersString);
367
371
  }
368
- // We may not have launched this one (we restarted, or it predates process records), so make sure it is recorded before leaving it alone
369
- await ensureProcessRecord({ screenName, folder, panePid: screen.pid, record: desired });
370
- await syncParametersTimelineFiles(folder, [{ pid: parseInt(screen.pid) || undefined, aliveWindow: desired.aliveWindow, parameters }]);
372
+ // We may not have started this one (we restarted, or it predates process records), so make sure it is recorded before leaving it alone
373
+ await ensureProcessRecord({ screenName, folder, panePid, record });
374
+ await syncParametersTimelineFiles(folder, [{ pid: parseInt(panePid) || undefined, aliveWindow, parameters }]);
375
+ if (takeoverAt !== undefined) {
376
+ if (!canonicalUp) {
377
+ // The version this one is waiting to replace is gone, so there is no overlap left to serve - take the name now instead of sitting under the future one
378
+ console.log(red(`Nothing is running as ${canonicalScreenName}, so ${screenName} takes it over now instead of waiting until ${new Date(takeoverAt).toISOString()}`));
379
+ await takeoverScreenLocked({ canonicalScreenName, folder });
380
+ } else {
381
+ // SMOOTH PATH: both versions are up, the old one serving and the new one waiting. A restart loses the timer that retires the old one, so it is put back every time we see it still waiting.
382
+ scheduleTakeover({ canonicalScreenName, folder, killTime: takeoverAt });
383
+ }
384
+ }
371
385
  console.log(green(`Verified ${magenta(screenName)} is running`));
372
386
  return;
373
387
  }
374
388
 
375
- console.log(`Resyncing ${magenta(screenName)}, with ${newParametersString}, isRunning = ${screen?.isProcessRunning}`);
389
+ // RUNNING is wrong - either nothing is alive, or what is alive is the wrong version. Starting is the only thing that fixes it.
390
+ if (!isUp) {
391
+ // Nothing should ever stop on its own: either it crashed, or it never came up
392
+ console.log(red(`Nothing is running as ${screenName}, starting it: ${newParametersString}`));
393
+ } else {
394
+ // SMOOTH PATH: the previous version is up and this replaces it with a new one, which is exactly what a deploy is
395
+ console.log(green(`Starting a new version as ${magenta(screenName)}, replacing the one running there: ${newParametersString}`));
396
+ }
397
+
398
+ // Only now does the folder have to hold this version, which is why the git and yarn work sits behind the check above rather than running on every resync
399
+ let gitFolder = folder + "git/";
400
+ await fs.promises.mkdir(gitFolder, { recursive: true });
401
+ if (parameters.gitRef) {
402
+ let prevGitRef = "";
403
+ try {
404
+ prevGitRef = await getGitRefLive(gitFolder);
405
+ } catch { }
406
+ await ensureGitSynced({ gitFolder, repoUrl: parameters.repoUrl, gitRef: parameters.gitRef });
407
+ let afterGitRef = await getGitRefLive(gitFolder);
408
+ // Reinstall when the ref changed OR node_modules is missing. A recovery re-clone can land on the same commit, so a ref-only check would leave the service with no node_modules.
409
+ let nodeModulesMissing = !await fsExistsAsync(gitFolder + "node_modules");
410
+ if (afterGitRef !== prevGitRef || nodeModulesMissing) {
411
+ console.log(green(`Yarn installing for ${magenta(screenName)} (ref ${prevGitRef} -> ${afterGitRef}, nodeModulesMissing=${nodeModulesMissing})`));
412
+ await runPromise(`yarn install --mutex network`, { cwd: gitFolder });
413
+ }
414
+ }
415
+
376
416
  await fs.promises.writeFile(parameterPath, newParametersString);
377
417
  void recordLaunch({
378
418
  serviceId,
379
- serviceKey: desired.serviceKey,
419
+ serviceKey: parameters.key,
380
420
  screenName: canonicalScreenName,
381
- machineId: desired.machineId,
382
- reason: screen?.isProcessRunning && "update" || "crashed",
421
+ machineId,
422
+ reason: isUp && "update" || "crashed",
383
423
  time: Date.now(),
384
424
  });
385
425
 
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({
426
+ // The node the version being replaced registered as, read before the launch overwrites the file with the new process's
427
+ let replacedNodeId = await readServiceNodeId(folder);
428
+
429
+ // Always started under the future name and renamed over the canonical one, releases and plain restarts alike: it is up before anything touches what it replaces, and it always gets a brand new pane - which is what makes it a distinct process with its own log.
430
+ let launchScreenName = futureScreenName;
431
+ let launchedPid = await runScreenCommand({
389
432
  screenName: launchScreenName,
390
433
  folder,
391
434
  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,
435
+ record,
395
436
  });
437
+ // SMOOTH PATH: the command is running as of the line above, so the overlap the version it replaces gets is measured from here
438
+ if (takeoverAt !== undefined) {
439
+ scheduleTakeover({ canonicalScreenName, folder, killTime: takeoverAt, replacedNodeId });
440
+ }
396
441
 
397
442
  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) {
443
+ let launchedPanePid = await getScreenPanePid(launchScreenName);
444
+ if (!launchedPanePid || !await isScreenRunningProcess(launchedPanePid)) {
401
445
  let prefix = getTmuxPrefix();
402
446
  let logs = await runPromise(`${prefix}tmux capture-pane -t ${launchScreenName} -p`);
403
447
  throw new Error(`${logs}\n\nService ${magenta(launchScreenName)} is not running after starting. Trying again in ${formatTime(MACHINE_RESYNC_INTERVAL)}, or on next change. Last logs above.`);
404
448
  }
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 });
449
+ console.log(green(`${magenta(launchScreenName)} is verified to be running (pid ${launchedPid})`));
450
+ // Take the canonical name now unless something is still serving under it - waiting out an overlap that nothing is serving would leave the name empty for no reason. SMOOTH PATH during a release is neither: the version being replaced is still up, so this one waits under the future name until its timer fires.
451
+ // AFTER: EXISTS correct VERSION correct RUNNING correct, under the canonical name, or under the future one with the version it is replacing still serving
452
+ if (takeoverAt === undefined || !canonicalUp) {
453
+ await takeoverScreenLocked({ canonicalScreenName, folder, replacedNodeId });
410
454
  }
411
- await syncParametersTimelineFiles(folder, [{ pid: parseInt(launched.pid) || undefined, aliveWindow: desired.aliveWindow, parameters }]);
455
+ await syncParametersTimelineFiles(folder, [{ pid: parseInt(launchedPanePid) || undefined, aliveWindow, parameters }]);
412
456
 
413
457
  launchCount++;
414
458
  lastLaunchedTime = Date.now();
415
- });
459
+ }));
416
460
  } catch (e: any) {
417
461
  // Counted on failure too, as a service that keeps crashing is only visible as a launch count climbing faster than everyone else's
418
462
  launchCount++;
419
463
  lastLaunchedTime = Date.now();
420
- console.error(`Error resyncing ${magenta(screenName)} (${serviceId}): ${e.stack}`);
464
+ console.error(`Error resyncing ${magenta(canonicalScreenName)} (${serviceId}): ${e.stack}`);
421
465
  machineInfo.services[serviceId].errorFromLastRun = e.stack;
422
466
  }
423
467
  machineInfo.services[serviceId].totalTimesLaunched = launchCount;
424
468
  machineInfo.services[serviceId].lastLaunchedTime = lastLaunchedTime;
425
469
  lastLaunchedTimePerService.set(serviceId, lastLaunchedTime);
426
470
  launchesPerService.set(serviceId, launchCount);
471
+ return usedScreenNames;
427
472
  }
428
473
 
429
474
  function sameRestartParameters(prevParametersJSON: string, next: ServiceParameters): boolean {
@@ -445,46 +490,36 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
445
490
 
446
491
  let machineInfo = await getLiveMachineInfo();
447
492
 
493
+ // The whole machine's screens, which is what the record maintenance and the kill sweep are about. Converging a screen asks about that one screen itself.
448
494
  let screenState = await getScreenState();
449
- let screenStateMap = new Map(screenState.map(s => [s.screenName, s]));
450
- // A process is alive while a pane with its pid still exists AND that pid still reports the start time it was named after - a reused pid is a different process
451
- let panePids = new Set(screenState.map(x => x.pid));
495
+ // A process is alive while its pane still exists, still reports the start time it was named after (a reused pid is a different process), and still has something running in it - a pane whose command exited is a dead process, not a live one
496
+ let running = new Set(screenState.filter(x => x.isProcessRunning).map(x => x.pid));
452
497
  await syncProcessRecords({
453
498
  isAlive: async record => {
454
- let pid = String(record.pid);
455
- if (!panePids.has(pid)) return false;
456
- return await getProcessStartTime(pid) === record.startTime;
499
+ if (!running.has(record.pid)) return false;
500
+ return await getProcessStartTime(record.pid) === record.startTime;
457
501
  },
458
502
  now: Date.now(),
459
503
  });
460
504
  let root = os.homedir() + "/" + SERVICE_FOLDER;
461
- let desiredScreens = getDesiredScreens(relevantConfigs, machineId, root);
505
+ let instances = getMachineInstances(relevantConfigs, machineId);
462
506
 
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
507
+ // The fast-path results are memoized per resync, so a folder shared by two instances is only traversed once
464
508
  gitFastPathChecks.clear();
465
- for (let desired of desiredScreens) {
466
- if (desired.outgoing || !desired.parameters.gitRef) continue;
467
- void checkGitSyncedAndClean(desired.folder + "git/", desired.parameters.gitRef);
468
- }
469
509
 
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 })));
510
+ // Every instance converges concurrently. Each has its own folder, its own records and its own lock, and yarn install takes a network mutex, so there is nothing to serialize behind - one slow git sync or shutdown no longer delays everything after it.
511
+ let usedScreenNames = new Set((await Promise.all(instances.map(instance => ensureScreen({ ...instance, machineInfo })))).flat());
472
512
 
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
- }
513
+ // Anything no instance claimed is not supposed to exist
479
514
  for (let { screenName } of screenState) {
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;
515
+ if (usedScreenNames.has(screenName)) continue;
516
+ // Already being taken down. One left over from a restart is not, so it still gets cleaned up here.
517
+ if (isRetiring(screenName)) continue;
483
518
  // Detached, so a screen that takes its full grace period to go down doesn't hold up the end of the resync
484
519
  void (async () => {
485
520
  // Nothing is replacing this one, so its registration and the file it wrote both go
486
521
  let folder = root + screenName + "/";
487
- let nodeId = (await findRunningRecord(folder, screenName))?.nodeId || await readServiceNodeId(folder);
522
+ let nodeId = await readServiceNodeId(folder);
488
523
  if (nodeId) {
489
524
  await removeServiceNode({ folder, nodeId, ownsFile: true });
490
525
  }
@@ -492,9 +527,6 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
492
527
  })();
493
528
  }
494
529
 
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);
497
-
498
530
  await machineInfos.set(machineId, machineInfo);
499
531
  console.log(`${magenta(`Resynced ${relevantConfigs.length} services`)}:\n${JSON.stringify(machineInfo, null, 2)}`);
500
532
 
@@ -509,34 +541,6 @@ async function resyncServices() {
509
541
  }
510
542
  }
511
543
 
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.
513
- let cancelReleaseResync: (() => void) | undefined;
514
- function scheduleReleaseResync(desiredScreens: DesiredScreen[]) {
515
- let now = Date.now();
516
- let next: number | undefined;
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;
522
- }
523
- }
524
- if (cancelReleaseResync) {
525
- cancelReleaseResync();
526
- cancelReleaseResync = undefined;
527
- }
528
- if (next === undefined) return;
529
- console.log(`Scheduling a resync for the next release boundary at ${new Date(next).toLocaleString()}`);
530
- cancelReleaseResync = setPreciseTimeout({
531
- time: next,
532
- callback: () => {
533
- cancelReleaseResync = undefined;
534
- void resyncServices();
535
- },
536
- });
537
- }
538
-
539
-
540
544
  async function isPIDRunning(pid: string) {
541
545
  try {
542
546
  // Use process.kill with signal 0 to test if process exists without actually killing it
@@ -83,16 +83,18 @@ class MachineControllerBase {
83
83
  }
84
84
  public async streamProcessOutput(config: {
85
85
  folder: string;
86
- launchId: string;
86
+ pid: string;
87
+ startTime: number;
87
88
  callbackId: string;
88
89
  }): Promise<void> {
89
90
  let caller = SocketFunction.getCaller();
90
91
  await streamProcessOutput({
91
92
  folder: config.folder,
92
- launchId: config.launchId,
93
+ pid: config.pid,
94
+ startTime: config.startTime,
93
95
  onData: async (data) => {
94
96
  await MachineControllerClient.nodes[caller.nodeId].onScreenOutput({
95
- launchId: config.launchId,
97
+ pid: config.pid,
96
98
  data,
97
99
  callbackId: config.callbackId,
98
100
  });
@@ -106,14 +108,16 @@ class MachineControllerBase {
106
108
  public async watchOtherProcessOutput(config: {
107
109
  nodeId: string;
108
110
  folder: string;
109
- launchId: string;
111
+ pid: string;
112
+ startTime: number;
110
113
  callbackId: string;
111
114
  }) {
112
115
  let caller = SocketFunction.getCaller();
113
116
  forwardedCallbacks.set(config.callbackId, caller.nodeId);
114
117
  await MachineController(config.nodeId).streamProcessOutput.promise({
115
118
  folder: config.folder,
116
- launchId: config.launchId,
119
+ pid: config.pid,
120
+ startTime: config.startTime,
117
121
  callbackId: config.callbackId,
118
122
  });
119
123
  }
@@ -170,7 +174,8 @@ let callbacks = new Map<string, (data: string) => Promise<void>>();
170
174
  export async function watchProcessOutput(config: {
171
175
  nodeId: string;
172
176
  folder: string;
173
- launchId: string;
177
+ pid: string;
178
+ startTime: number;
174
179
  callbackId: string;
175
180
  onData: (data: string) => Promise<void>;
176
181
  }) {
@@ -179,7 +184,8 @@ export async function watchProcessOutput(config: {
179
184
  await MachineController(SocketFunction.browserNodeId()).watchOtherProcessOutput.promise({
180
185
  nodeId: config.nodeId,
181
186
  folder: config.folder,
182
- launchId: config.launchId,
187
+ pid: config.pid,
188
+ startTime: config.startTime,
183
189
  callbackId,
184
190
  });
185
191
  }
@@ -192,14 +198,14 @@ export async function stopWatchingProcessOutput(config: {
192
198
  }
193
199
  class MachineControllerClientBase {
194
200
  public async onScreenOutput(config: {
195
- launchId: string;
201
+ pid: string;
196
202
  data: string;
197
203
  callbackId: string;
198
204
  }): Promise<void> {
199
205
  let forwardToNodeId = forwardedCallbacks.get(config.callbackId);
200
206
  if (forwardToNodeId) {
201
207
  await MachineControllerClient.nodes[forwardToNodeId].onScreenOutput({
202
- launchId: config.launchId,
208
+ pid: config.pid,
203
209
  data: config.data,
204
210
  callbackId: config.callbackId,
205
211
  });
@@ -1,6 +1,7 @@
1
1
  import os from "os";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
+ import { fsExistsAsync } from "../fs";
4
5
  import { SERVICE_FOLDER, ServiceParameters } from "./machineSchema";
5
6
 
6
7
  // One log file per process, appended to until it passes LOG_SIZE_LIMIT, then emptied wholesale. Watchers see the file shrink and re-seek to the start. A watcher attaching in the moment right after a reset sees an empty log, which at this size is rare enough to be worth how much simpler this is than rolling files.
@@ -16,53 +17,43 @@ const LOG_SUFFIX = ".log";
16
17
  const PIPE_SCRIPT_SUFFIX = ".pipe.sh";
17
18
  const TAIL_SCRIPT_SUFFIX = ".tail.sh";
18
19
 
19
- /** One launch of one service instance: the process, the configuration it was launched with, and its output. Written when the process is launched, and updated only to record its pid and then its death. */
20
+ /** One process: the configuration it was started with, and its output. A process is named by its pid and the start time the OS reports for that pid - the pid alone gets reused, and both halves can be re-checked against the machine at any time instead of trusted from our own bookkeeping. */
20
21
  export type ProcessRecord = {
21
- launchId: string;
22
- /** The instance folder this process runs out of. A release's future process shares its canonical instance's folder, so this is what owns the logs, not the tmux session name. */
22
+ pid: string;
23
+ /** The start time the OS reports for the pid */
24
+ startTime: number;
25
+ /** The instance folder this process runs out of. A replacement shares the folder with the process it replaces, so this is what owns the logs, not the tmux session name. */
23
26
  folder: string;
24
- /** The tmux session, which a takeover renames from the future name to the canonical one */
27
+ /** The tmux session, which a takeover renames */
25
28
  screenName: string;
26
29
  serviceId: string;
27
30
  serviceKey: string;
28
31
  /** Which instance of the service this is, on this machine */
29
32
  index: number;
30
33
  machineId: string;
31
- /** The start time the OS reports for the pid, which is also half of the launchId */
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
+ /** The node this process registered as, read out of the folder before any replacement is started - after that the file belongs to the replacement. */
34
35
  nodeId?: string;
35
36
  /** When we first noticed the process was gone. Absent while it is running. */
36
37
  deadTime?: number;
37
- pid?: number;
38
- /** The parameters it was launched with, with template variables already resolved */
38
+ /** The parameters it was started with, with template variables already resolved */
39
39
  parameters: ServiceParameters;
40
40
  };
41
41
 
42
- export function getProcessLogPath(folder: string, launchId: string): string {
43
- return path.join(folder, PROCESS_FOLDER, launchId + LOG_SUFFIX);
44
- }
45
- function getRecordPath(folder: string, launchId: string): string {
46
- return path.join(folder, PROCESS_FOLDER, launchId + RECORD_SUFFIX);
42
+ // A process's files all share this name, so its whole footprint is found, and deleted, together
43
+ function getProcessFileName(pid: string, startTime: number): string {
44
+ return `${pid}-${startTime}`;
47
45
  }
48
- // The scripts live beside the log so a process's entire footprint is one set of files, deleted together
49
- export function getPipeScriptPath(folder: string, launchId: string): string {
50
- return path.join(folder, PROCESS_FOLDER, launchId + PIPE_SCRIPT_SUFFIX);
46
+ export function getProcessLogPath(folder: string, pid: string, startTime: number): string {
47
+ return path.join(folder, PROCESS_FOLDER, getProcessFileName(pid, startTime) + LOG_SUFFIX);
51
48
  }
52
- export function getTailScriptPath(folder: string, launchId: string): string {
53
- return path.join(folder, PROCESS_FOLDER, launchId + TAIL_SCRIPT_SUFFIX);
49
+ export function getPipeScriptPath(folder: string, pid: string, startTime: number): string {
50
+ return path.join(folder, PROCESS_FOLDER, getProcessFileName(pid, startTime) + PIPE_SCRIPT_SUFFIX);
54
51
  }
55
-
56
- /** A process identifies itself: its pid, plus the start time the OS reports for it. The pid alone is reused, but a pid running since a specific second is one process, and both halves can be re-checked against the machine at any time instead of trusted from our own bookkeeping. */
57
- export function createLaunchId(pid: string, startTime: number): string {
58
- return `${pid}-${startTime}`;
52
+ export function getTailScriptPath(folder: string, pid: string, startTime: number): string {
53
+ return path.join(folder, PROCESS_FOLDER, getProcessFileName(pid, startTime) + TAIL_SCRIPT_SUFFIX);
59
54
  }
60
- export function parseLaunchId(launchId: string): { pid: string; startTime: number } {
61
- let separator = launchId.lastIndexOf("-");
62
- return {
63
- pid: launchId.slice(0, separator),
64
- startTime: parseInt(launchId.slice(separator + 1)) || 0,
65
- };
55
+ function getRecordPath(folder: string, pid: string, startTime: number): string {
56
+ return path.join(folder, PROCESS_FOLDER, getProcessFileName(pid, startTime) + RECORD_SUFFIX);
66
57
  }
67
58
 
68
59
  /** The shell a screen's output is piped through: appends every line, and empties the file once it grows past the limit. */
@@ -101,16 +92,16 @@ while true; do
101
92
  done`;
102
93
  }
103
94
 
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
- );
95
+ /** One process's record, read straight from the file its pid and start time name. */
96
+ export async function readProcessRecord(folder: string, pid: string, startTime: number): Promise<ProcessRecord | undefined> {
97
+ let file = getRecordPath(folder, pid, startTime);
98
+ if (!await fsExistsAsync(file)) return undefined;
99
+ return JSON.parse(await fs.promises.readFile(file, "utf8")) as ProcessRecord;
109
100
  }
110
101
 
111
102
  export async function writeProcessRecord(record: ProcessRecord): Promise<void> {
112
103
  await fs.promises.mkdir(path.join(record.folder, PROCESS_FOLDER), { recursive: true });
113
- await fs.promises.writeFile(getRecordPath(record.folder, record.launchId), JSON.stringify(record));
104
+ await fs.promises.writeFile(getRecordPath(record.folder, record.pid, record.startTime), JSON.stringify(record));
114
105
  }
115
106
 
116
107
  async function readFolderRecords(folder: string): Promise<ProcessRecord[]> {
@@ -147,10 +138,10 @@ export async function listProcessRecords(): Promise<ProcessRecord[]> {
147
138
 
148
139
  async function removeProcessRecord(record: ProcessRecord): Promise<void> {
149
140
  let files = [
150
- getRecordPath(record.folder, record.launchId),
151
- getProcessLogPath(record.folder, record.launchId),
152
- getPipeScriptPath(record.folder, record.launchId),
153
- getTailScriptPath(record.folder, record.launchId),
141
+ getRecordPath(record.folder, record.pid, record.startTime),
142
+ getProcessLogPath(record.folder, record.pid, record.startTime),
143
+ getPipeScriptPath(record.folder, record.pid, record.startTime),
144
+ getTailScriptPath(record.folder, record.pid, record.startTime),
154
145
  ];
155
146
  for (let file of files) {
156
147
  try {
@@ -169,11 +160,11 @@ export async function syncProcessRecords(config: {
169
160
  for (let record of await listProcessRecords()) {
170
161
  if (record.deadTime === undefined && !await config.isAlive(record)) {
171
162
  record.deadTime = config.now;
172
- console.log(`Process ${record.launchId} (${record.screenName}) is no longer running, marking it dead. Its log is deleted at ${new Date(record.deadTime + DEAD_PROCESS_RETENTION).toISOString()}`);
163
+ console.log(`Process ${record.pid} (${record.screenName}) is no longer running, marking it dead. Its log is deleted at ${new Date(record.deadTime + DEAD_PROCESS_RETENTION).toISOString()}`);
173
164
  await writeProcessRecord(record);
174
165
  }
175
166
  if (record.deadTime !== undefined && config.now - record.deadTime > DEAD_PROCESS_RETENTION) {
176
- console.log(`Deleting the log of process ${record.launchId} (${record.screenName}), dead since ${new Date(record.deadTime).toISOString()}`);
167
+ console.log(`Deleting the log of process ${record.pid} (${record.screenName}), dead since ${new Date(record.deadTime).toISOString()}`);
177
168
  await removeProcessRecord(record);
178
169
  }
179
170
  }
@@ -2,7 +2,7 @@ import os from "os";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
4
  import { spawn, ChildProcess } from "child_process";
5
- import { lazy } from "socket-function/src/caching";
5
+ import { cache, lazy } from "socket-function/src/caching";
6
6
  import { measureWrap } from "socket-function/src/profiling/measure";
7
7
  import { delay, runInSerial } from "socket-function/src/batching";
8
8
  import { timeInSecond } from "socket-function/src/misc";
@@ -13,7 +13,7 @@ import { forceRemoveNode } from "../-f-node-discovery/NodeDiscovery";
13
13
  import { fsExistsAsync } from "../fs";
14
14
  import { PromiseObj } from "../promise";
15
15
  import { SERVICE_FOLDER, SERVICE_NODE_FILE_NAME } from "./machineSchema";
16
- import { ProcessRecord, createLaunchId, getLogPipeScript, getLogTailScript, getProcessLogPath, getPipeScriptPath, getTailScriptPath, listProcessRecords, findRunningRecord, writeProcessRecord } from "./processLogs";
16
+ import { ProcessRecord, getLogPipeScript, getLogTailScript, getProcessLogPath, getPipeScriptPath, getTailScriptPath, readProcessRecord, writeProcessRecord } from "./processLogs";
17
17
  import { setPreciseTimeout } from "../misc";
18
18
 
19
19
  // Running, inspecting and killing the tmux screens services run in. This layer only knows "here is a configuration, run it" - which version should be running when is the deploy logic's problem.
@@ -38,25 +38,29 @@ export function getFutureScreenName(canonicalScreenName: string): string {
38
38
 
39
39
 
40
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;
41
+ const getScreenLock = cache((canonicalScreenName: string) => runInSerial(async (run: () => Promise<unknown>) => await run()));
42
+ export async function withScreenLock<T>(canonicalScreenName: string, run: () => Promise<T>): Promise<T> {
43
+ return await getScreenLock(canonicalScreenName)(run) as T;
48
44
  }
49
45
 
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> {
46
+ /** The pid of a screen's pane, or undefined when there is no such session. Asks about the one screen instead of listing every session on the machine. */
47
+ export async function getScreenPanePid(screenName: string): Promise<string | undefined> {
52
48
  let prefix = getTmuxPrefix();
49
+ let output: string;
53
50
  try {
54
- await runPromise(`${prefix}tmux has-session -t ${screenName}`, { quiet: true });
55
- return true;
51
+ output = await runPromise(`${prefix}tmux list-panes -t ${screenName} -F "#{pane_pid}"`, { quiet: true });
56
52
  } catch {
57
- // has-session exits non-zero for "no such session", which is an answer, not a failure
58
- return false;
53
+ // Exits non-zero for "no such session", which is an answer, not a failure
54
+ return undefined;
59
55
  }
56
+ return output.trim().split("\n")[0].trim() || undefined;
57
+ }
58
+
59
+ /** Whether a screen exists AND has a process running in it - the only sense in which a service is up. */
60
+ export async function isScreenUp(screenName: string): Promise<boolean> {
61
+ let panePid = await getScreenPanePid(screenName);
62
+ if (!panePid) return false;
63
+ return await isScreenRunningProcess(panePid);
60
64
  }
61
65
 
62
66
  /** 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. */
@@ -215,104 +219,76 @@ export const runScreenCommand = measureWrap(async function runScreenCommand(conf
215
219
  command: string;
216
220
  // Defaults to the folder derived from screenName; a future screen passes its canonical screen's folder
217
221
  folder?: string;
218
- // Identifies the process this launch creates, so its log and its configuration are stored against it
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 };
222
+ // Identifies the process this start creates, so its log and its configuration are stored against it
223
+ record: Omit<ProcessRecord, "pid" | "startTime" | "folder" | "screenName">;
222
224
  }): Promise<string> {
223
225
  let prefix = getTmuxPrefix();
224
226
  let screenName = config.screenName;
225
227
 
226
- // A launch is ALWAYS a brand new session. Callers launch into the "-future" name and rename it over the canonical one once the new process is up, so starting a replacement never waits on the old process going away. Nothing is killed here.
227
- // A leftover session under this name is from a launch that died before its takeover, so it is dead weight rather than something we are replacing.
228
- if ((await getScreenState(false)).some(x => x.screenName === screenName)) {
229
- console.log(red(`Removing the leftover session ${screenName} from a launch that never completed`));
228
+ // Always a brand new session, so the pane is always new - and the pane's pid is what makes this a distinct process with its own log. A session already under this name is debris from a start that never got taken over.
229
+ if (await getScreenPanePid(screenName)) {
230
+ console.log(red(`Removing the leftover session ${screenName} from a start that never completed`));
230
231
  await runPromise(`${prefix}tmux kill-session -t ${screenName}`);
231
232
  }
232
233
  await runPromise(`${prefix}tmux new -s ${screenName} -d`);
233
234
 
234
235
  let folder = config.folder || os.homedir() + "/" + SERVICE_FOLDER + screenName + "/";
235
236
 
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
-
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.
246
- let panePid = (await getScreenState(false)).find(x => x.screenName === screenName)?.pid;
247
- if (!panePid) {
237
+ // The pane we are about to run the command in names 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 trusts our bookkeeping about what is still running.
238
+ let pid = await getScreenPanePid(screenName);
239
+ if (!pid) {
248
240
  throw new Error(`Screen ${screenName} does not exist after creating it, so there is no process to run the command in`);
249
241
  }
250
- let startTime = await getProcessStartTime(panePid);
251
- let launchId = createLaunchId(panePid, startTime);
252
- await writeProcessRecord({
253
- ...config.record,
254
- launchId,
255
- folder,
256
- screenName,
257
- pid: parseInt(panePid) || undefined,
258
- startTime,
259
- });
242
+ let startTime = await getProcessStartTime(pid);
243
+ await writeProcessRecord({ ...config.record, pid, startTime, folder, screenName });
244
+ // The pipe is attached before the command runs, so the log holds everything the process ever printed
245
+ await setupPipePane({ screenName, folder, pid, startTime });
260
246
  await runPromise(`${prefix}tmux send-keys -t ${screenName} 'cd ${folder}git' Enter`);
261
247
  let command = `#!/bin/bash
262
248
  ${config.command}
263
249
  `;
264
250
  await fs.promises.writeFile(folder + "command.sh", command);
251
+ // Sending the command is the LAST thing this does, so the caller's next line runs the instant the process starts
265
252
  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
- }
274
-
275
- await setupPipePane({ screenName, folder, launchId });
276
- return launchId;
253
+ return pid;
277
254
  });
278
255
 
279
- /** Links an already-running screen to a process record, creating one when this process has never been recorded - it was launched before we restarted, or before process records existed at all. Idempotent: a process we already know about is left alone, so the pipe is only attached the first time we adopt it.
256
+ /** Links an already-running screen to a process record, creating one when this process has never been recorded - it was started before we restarted, or before process records existed at all. Idempotent: a process we already know about is left alone, so the pipe is only attached the first time we adopt it.
280
257
  *
281
- * The screen identifies itself (pane pid + the start time the OS reports for it), so adoption is just naming what is already there - there is nothing to reconcile and nothing to guess. */
258
+ * The screen names the process itself (pane pid + the start time the OS reports for it), so adoption is just writing down what is already there. */
282
259
  export async function ensureProcessRecord(config: {
283
260
  screenName: string;
284
261
  folder: string;
285
262
  panePid: string;
286
- record: Omit<ProcessRecord, "launchId" | "folder" | "screenName" | "startTime" | "pid">;
263
+ record: Omit<ProcessRecord, "pid" | "startTime" | "folder" | "screenName">;
287
264
  }): Promise<void> {
288
- let startTime = await getProcessStartTime(config.panePid);
265
+ let pid = config.panePid;
266
+ let startTime = await getProcessStartTime(pid);
289
267
  if (!startTime) return;
290
- let launchId = createLaunchId(config.panePid, startTime);
291
- let existing = (await listProcessRecords()).find(x => x.launchId === launchId && x.folder === config.folder);
268
+ let existing = await readProcessRecord(config.folder, pid, startTime);
292
269
  if (existing) {
293
270
  // A takeover renames the session, so the record follows the name it now runs under
294
271
  if (existing.screenName === config.screenName) return;
295
272
  await writeProcessRecord({ ...existing, screenName: config.screenName });
296
273
  return;
297
274
  }
298
- console.log(`Adopting already-running process ${launchId} on screen ${config.screenName}, which has no record yet`);
275
+ console.log(`Adopting already-running process ${pid} on screen ${config.screenName}, which has no record yet`);
299
276
  await writeProcessRecord({
300
277
  ...config.record,
301
- launchId,
278
+ pid,
279
+ startTime,
302
280
  folder: config.folder,
303
281
  screenName: config.screenName,
304
- pid: parseInt(config.panePid) || undefined,
305
- startTime,
306
282
  });
307
283
  // Its output was going to whatever the previous incarnation pointed at (or nowhere), so point it at its own log from here on
308
- await setupPipePane({ screenName: config.screenName, folder: config.folder, launchId });
284
+ await setupPipePane({ screenName: config.screenName, folder: config.folder, pid, startTime });
309
285
  }
310
286
 
311
- // Points the screen's pipe-pane at this launch's own log file.
312
- async function setupPipePane(config: { screenName: string; folder: string; launchId: string }) {
287
+ // Points the screen's pipe-pane at this process's own log file.
288
+ async function setupPipePane(config: { screenName: string; folder: string; pid: string; startTime: number }) {
313
289
  let prefix = getTmuxPrefix();
314
- let logPath = getProcessLogPath(config.folder, config.launchId);
315
- let pipeScript = getPipeScriptPath(config.folder, config.launchId);
290
+ let logPath = getProcessLogPath(config.folder, config.pid, config.startTime);
291
+ let pipeScript = getPipeScriptPath(config.folder, config.pid, config.startTime);
316
292
  await fs.promises.mkdir(path.dirname(logPath), { recursive: true });
317
293
  await fs.promises.writeFile(pipeScript, getLogPipeScript(logPath));
318
294
  await runPromise(`chmod +x ${pipeScript}`);
@@ -320,42 +296,69 @@ async function setupPipePane(config: { screenName: string; folder: string; launc
320
296
  }
321
297
 
322
298
  /** Retires whatever is running under the canonical name and renames the future screen onto it. Everything it needs is derived from the two names, so it costs two `has-session` checks - the kill time is when this runs, not when a resync gets around to it. Returns whether there was a future screen to take over. */
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 });
299
+ export async function takeoverScreen(config: {
300
+ canonicalScreenName: string;
301
+ folder: string;
302
+ /** The node the version being replaced registered as, read before the replacement overwrote the folder's file. */
303
+ replacedNodeId?: string;
304
+ }): Promise<boolean> {
305
+ return await withScreenLock(config.canonicalScreenName, () => takeoverScreenLocked(config));
306
+ }
307
+
308
+ /** The takeover itself. Callers already holding this screen's lock use it directly - taking the lock again from inside it would wait on themselves. */
309
+ export async function takeoverScreenLocked(config: {
310
+ canonicalScreenName: string;
311
+ folder: string;
312
+ replacedNodeId?: string;
313
+ }): Promise<boolean> {
314
+ let { canonicalScreenName, folder, replacedNodeId } = config;
315
+ let futureScreenName = getFutureScreenName(canonicalScreenName);
316
+ let futurePid = await getScreenPanePid(futureScreenName);
317
+ if (!futurePid) return false;
318
+ let prefix = getTmuxPrefix();
319
+
320
+ let outgoingPid = await getScreenPanePid(canonicalScreenName);
321
+ if (outgoingPid) {
322
+ // Renamed out of the way immediately, so the canonical name is free at once and the old process winds down on its own
323
+ let retiringScreenName = getRetiringScreenName(canonicalScreenName, Date.now());
324
+ await runPromise(`${prefix}tmux rename-session -t ${canonicalScreenName} ${retiringScreenName}`);
325
+ await renameProcessScreen({ folder, panePid: outgoingPid, screenName: retiringScreenName });
326
+ if (replacedNodeId) {
327
+ // The file in the folder belongs to the replacement now, so only the registration goes
328
+ void removeServiceNode({ folder, nodeId: replacedNodeId, ownsFile: false });
348
329
  }
349
- return true;
350
- });
330
+ retiringScreens.add(retiringScreenName);
331
+ void killScreen({ screenName: retiringScreenName }).finally(() => {
332
+ retiringScreens.delete(retiringScreenName);
333
+ });
334
+ }
335
+ console.log(green(`Renaming ${futureScreenName} to ${canonicalScreenName} (takeover complete)`));
336
+ await runPromise(`${prefix}tmux rename-session -t ${futureScreenName} ${canonicalScreenName}`);
337
+ await renameProcessScreen({ folder, panePid: futurePid, screenName: canonicalScreenName });
338
+ return true;
339
+ }
340
+
341
+ /** Points a process's record at the session name it now runs under. The pid is the key, so this reads exactly one file. */
342
+ async function renameProcessScreen(config: { folder: string; panePid: string; screenName: string }): Promise<void> {
343
+ let startTime = await getProcessStartTime(config.panePid);
344
+ if (!startTime) return;
345
+ let record = await readProcessRecord(config.folder, config.panePid, startTime);
346
+ if (!record || record.screenName === config.screenName) return;
347
+ await writeProcessRecord({ ...record, screenName: config.screenName });
351
348
  }
352
349
 
353
350
  // canonical screen name -> the takeover we already have scheduled for it
354
351
  const scheduledTakeovers = new Map<string, { time: number; cancel: () => void }>();
355
352
 
356
353
  /** Schedules the takeover for the moment the old process's overlap runs out. The resync loop still does the same takeover whenever it notices the time has passed, which is what recovers this if we restart and lose the timer. */
357
- export function scheduleTakeover(config: { canonicalScreenName: string; folder: string; killTime: number }): void {
358
- let { canonicalScreenName, folder, killTime } = config;
354
+ export function scheduleTakeover(config: {
355
+ canonicalScreenName: string;
356
+ folder: string;
357
+ killTime: number;
358
+ /** The node the version being replaced registered as, read before the replacement overwrote the folder's file. */
359
+ replacedNodeId?: string;
360
+ }): void {
361
+ let { canonicalScreenName, folder, killTime, replacedNodeId } = config;
359
362
  let existing = scheduledTakeovers.get(canonicalScreenName);
360
363
  if (existing?.time === killTime) return;
361
364
  existing?.cancel();
@@ -364,12 +367,25 @@ export function scheduleTakeover(config: { canonicalScreenName: string; folder:
364
367
  time: killTime,
365
368
  callback: () => {
366
369
  scheduledTakeovers.delete(canonicalScreenName);
367
- void takeoverScreen({ canonicalScreenName, folder });
370
+ void takeoverScreen({ canonicalScreenName, folder, replacedNodeId });
368
371
  },
369
372
  });
370
373
  scheduledTakeovers.set(canonicalScreenName, { time: killTime, cancel });
371
374
  }
372
375
 
376
+ // The screens whose shutdown is already running. Nothing else should start a second one, but a screen left behind by a restart is NOT in here, so the unused-screen sweep still cleans it up.
377
+ const retiringScreens = new Set<string>();
378
+ export function isRetiring(screenName: string): boolean {
379
+ return retiringScreens.has(screenName);
380
+ }
381
+
382
+ /** Ends a session at once, with no notice - for a screen that should not exist at all, so there is nothing to wind down gracefully. Finding one is always a surprise, so the reason it was not supposed to be there is logged. */
383
+ export async function killScreenNow(screenName: string, reason: string): Promise<void> {
384
+ if (!await getScreenPanePid(screenName)) return;
385
+ console.log(red(`Unexpected screen ${screenName}: ${reason}. Killing it immediately.`));
386
+ await runPromise(`${getTmuxPrefix()}tmux kill-session -t ${screenName}`);
387
+ }
388
+
373
389
  // Kills a screen and nothing else. Which node the process registered as, and whether that registration should be dropped, is the deploy logic's business - it reads the node id off the process's record, which was taken before any replacement could overwrite the file.
374
390
  export const killScreen = measureWrap(async function killScreen(config: {
375
391
  screenName: string;
@@ -385,10 +401,11 @@ export const killScreen = measureWrap(async function killScreen(config: {
385
401
  /** 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. */
386
402
  export async function streamProcessOutput(config: {
387
403
  folder: string;
388
- launchId: string;
404
+ pid: string;
405
+ startTime: number;
389
406
  onData: (data: string) => Promise<void>;
390
407
  }) {
391
- let logPath = getProcessLogPath(config.folder, config.launchId);
408
+ let logPath = getProcessLogPath(config.folder, config.pid, config.startTime);
392
409
  let serialOnData = runInSerial(config.onData);
393
410
  let stopped = false;
394
411
  let childProcess: ChildProcess | undefined;
@@ -407,14 +424,14 @@ export async function streamProcessOutput(config: {
407
424
  const onDataWrapped = async (data: string) => {
408
425
  pendingDataCalls++;
409
426
  if (pendingDataCalls > MAX_PENDING_CALLS) {
410
- console.error(`Too many queued onData calls for ${config.launchId}, stopping stream.`);
427
+ console.error(`Too many queued onData calls for ${config.pid}, stopping stream.`);
411
428
  await stop();
412
429
  return;
413
430
  }
414
431
  try {
415
432
  await serialOnData(data);
416
433
  } catch (e: any) {
417
- console.log(`Callback for stream output ${config.launchId} failed. It probably just disconnected, almost certainly not an error: ${e.message}`);
434
+ console.log(`Callback for stream output ${config.pid} failed. It probably just disconnected, almost certainly not an error: ${e.message}`);
418
435
  await stop();
419
436
  } finally {
420
437
  pendingDataCalls--;
@@ -422,7 +439,7 @@ export async function streamProcessOutput(config: {
422
439
  };
423
440
 
424
441
  try {
425
- let tailScript = getTailScriptPath(config.folder, config.launchId);
442
+ let tailScript = getTailScriptPath(config.folder, config.pid, config.startTime);
426
443
  await fs.promises.writeFile(tailScript, getLogTailScript(logPath));
427
444
  await runPromise(`chmod +x ${tailScript}`);
428
445