querysub 0.570.0 → 0.571.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.571.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
  }
@@ -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, takeoverScreen, scheduleTakeover, withScreenLock } from "./processManager";
30
+ import { ProcessRecord, syncProcessRecords, listProcessRecords } from "./processLogs";
31
31
 
32
32
 
33
33
 
@@ -298,10 +298,9 @@ function getDesiredScreens(configs: ServiceConfig[], machineId: string, root: st
298
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
299
  async function ensureScreen(config: {
300
300
  desired: DesiredScreen;
301
- screenStateMap: Map<string, { screenName: string; isProcessRunning: boolean; pid: string }>;
302
301
  machineInfo: MachineInfo;
303
302
  }): Promise<void> {
304
- let { desired, screenStateMap, machineInfo } = config;
303
+ let { desired, machineInfo } = config;
305
304
  let { screenName, canonicalScreenName, folder, parameters, serviceId } = desired;
306
305
 
307
306
  let launchCount = launchesPerService.get(serviceId) || 0;
@@ -315,15 +314,16 @@ async function ensureScreen(config: {
315
314
 
316
315
  try {
317
316
  await withScreenLock(canonicalScreenName, async () => {
318
- let screen = screenStateMap.get(screenName);
317
+ // Asked of tmux here, not handed in: this decides everything from the names it was given
318
+ let panePid = await getScreenPanePid(screenName);
319
+ let isUp = !!panePid && await isScreenRunningProcess(panePid);
319
320
 
320
321
  if (desired.outgoing) {
321
322
  // Its replacement owns the folder now, so the only thing to do is keep its record straight and let it know when it goes down
322
- if (screen?.isProcessRunning) {
323
- await ensureProcessRecord({ screenName, folder, panePid: screen.pid, record: desired });
323
+ if (panePid && isUp) {
324
+ await ensureProcessRecord({ screenName, folder, panePid, record: desired });
324
325
  void (async () => {
325
- let running = await findRunningRecord(folder, screenName);
326
- let nodeId = running?.nodeId || await readServiceNodeId(folder);
326
+ let nodeId = await readServiceNodeId(folder);
327
327
  if (nodeId) {
328
328
  await notifyNodeShutdown(screenName, nodeId, desired.aliveWindow[1]);
329
329
  }
@@ -361,54 +361,61 @@ async function ensureScreen(config: {
361
361
  machineInfo.services[serviceId].nodeId = await fs.promises.readFile(nodePathId, "utf8");
362
362
  }
363
363
 
364
- if (sameRestartParameters(prevParameters, parameters) && screen?.isProcessRunning) {
364
+ if (isUp && panePid && sameRestartParameters(prevParameters, parameters)) {
365
365
  if (prevParameters !== newParametersString) {
366
366
  await fs.promises.writeFile(parameterPath, newParametersString);
367
367
  }
368
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 }]);
369
+ await ensureProcessRecord({ screenName, folder, panePid, record: desired });
370
+ await syncParametersTimelineFiles(folder, [{ pid: parseInt(panePid) || undefined, aliveWindow: desired.aliveWindow, parameters }]);
371
+ // A restart loses the timer that retires the version this one replaces, so it is put back every time we see it still waiting
372
+ if (desired.takeoverAt !== undefined) {
373
+ scheduleTakeover({ canonicalScreenName, folder, killTime: desired.takeoverAt });
374
+ }
371
375
  console.log(green(`Verified ${magenta(screenName)} is running`));
372
376
  return;
373
377
  }
374
378
 
375
- console.log(`Resyncing ${magenta(screenName)}, with ${newParametersString}, isRunning = ${screen?.isProcessRunning}`);
379
+ console.log(`Resyncing ${magenta(screenName)}, with ${newParametersString}, isUp = ${isUp}`);
376
380
  await fs.promises.writeFile(parameterPath, newParametersString);
377
381
  void recordLaunch({
378
382
  serviceId,
379
383
  serviceKey: desired.serviceKey,
380
384
  screenName: canonicalScreenName,
381
385
  machineId: desired.machineId,
382
- reason: screen?.isProcessRunning && "update" || "crashed",
386
+ reason: isUp && "update" || "crashed",
383
387
  time: Date.now(),
384
388
  });
385
389
 
390
+ // The node the version being replaced registered as, read before the launch overwrites the file with the new process's
391
+ let replacedNodeId = await readServiceNodeId(folder);
392
+
386
393
  // Every launch goes into the future name and renames over the canonical one, releases and plain restarts alike: the replacement is up before anything touches the old process, and it always gets a brand new pane - which is what makes it a distinct process with its own log.
387
394
  let launchScreenName = getFutureScreenName(canonicalScreenName);
388
- let launchId = await runScreenCommand({
395
+ let launchedPid = await runScreenCommand({
389
396
  screenName: launchScreenName,
390
397
  folder,
391
398
  command: parameters.command,
392
399
  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
400
  });
401
+ // The command is running as of the line above, so the overlap is measured from here
402
+ if (desired.takeoverAt !== undefined) {
403
+ scheduleTakeover({ canonicalScreenName, folder, killTime: desired.takeoverAt, replacedNodeId });
404
+ }
396
405
 
397
406
  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) {
407
+ let launchedPanePid = await getScreenPanePid(launchScreenName);
408
+ if (!launchedPanePid || !await isScreenRunningProcess(launchedPanePid)) {
401
409
  let prefix = getTmuxPrefix();
402
410
  let logs = await runPromise(`${prefix}tmux capture-pane -t ${launchScreenName} -p`);
403
411
  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
412
  }
405
- console.log(green(`${magenta(launchScreenName)} is verified to be running (process ${launchId})`));
413
+ console.log(green(`${magenta(launchScreenName)} is verified to be running (pid ${launchedPid})`));
406
414
  if (desired.takeoverAt === undefined) {
407
415
  // 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 });
416
+ await takeoverScreen({ canonicalScreenName, folder, replacedNodeId });
410
417
  }
411
- await syncParametersTimelineFiles(folder, [{ pid: parseInt(launched.pid) || undefined, aliveWindow: desired.aliveWindow, parameters }]);
418
+ await syncParametersTimelineFiles(folder, [{ pid: parseInt(launchedPanePid) || undefined, aliveWindow: desired.aliveWindow, parameters }]);
412
419
 
413
420
  launchCount++;
414
421
  lastLaunchedTime = Date.now();
@@ -445,30 +452,25 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
445
452
 
446
453
  let machineInfo = await getLiveMachineInfo();
447
454
 
455
+ // 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
456
  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));
457
+ // 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
458
+ let running = new Set(screenState.filter(x => x.isProcessRunning).map(x => x.pid));
452
459
  await syncProcessRecords({
453
460
  isAlive: async record => {
454
- let pid = String(record.pid);
455
- if (!panePids.has(pid)) return false;
456
- return await getProcessStartTime(pid) === record.startTime;
461
+ if (!running.has(record.pid)) return false;
462
+ return await getProcessStartTime(record.pid) === record.startTime;
457
463
  },
458
464
  now: Date.now(),
459
465
  });
460
466
  let root = os.homedir() + "/" + SERVICE_FOLDER;
461
467
  let desiredScreens = getDesiredScreens(relevantConfigs, machineId, root);
462
468
 
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
469
+ // The fast-path results are memoized per resync, so a folder two desired screens share is only traversed once
464
470
  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
471
 
470
472
  // Every screen converges concurrently. Each has its own folder, its own records and its own lock, and yarn install takes a network mutex, so there is nothing to serialize behind - one slow git sync or shutdown no longer delays everything after it.
471
- await Promise.all(desiredScreens.map(desired => ensureScreen({ desired, screenStateMap, machineInfo })));
473
+ await Promise.all(desiredScreens.map(desired => ensureScreen({ desired, machineInfo })));
472
474
 
473
475
  // Both names count as wanted: a screen that took over during this pass answers to the canonical one now, and one that is about to take over still answers to its future name
474
476
  let wantedNames = new Set<string>();
@@ -478,13 +480,13 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
478
480
  }
479
481
  for (let { screenName } of screenState) {
480
482
  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;
483
+ // Already being taken down. One left over from a restart is not, so it still gets cleaned up here.
484
+ if (isRetiring(screenName)) continue;
483
485
  // Detached, so a screen that takes its full grace period to go down doesn't hold up the end of the resync
484
486
  void (async () => {
485
487
  // Nothing is replacing this one, so its registration and the file it wrote both go
486
488
  let folder = root + screenName + "/";
487
- let nodeId = (await findRunningRecord(folder, screenName))?.nodeId || await readServiceNodeId(folder);
489
+ let nodeId = await readServiceNodeId(folder);
488
490
  if (nodeId) {
489
491
  await removeServiceNode({ folder, nodeId, ownsFile: true });
490
492
  }
@@ -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,61 @@ 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;
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
+ let { canonicalScreenName, folder, replacedNodeId } = config;
325
306
  return await withScreenLock(canonicalScreenName, async () => {
307
+ let futurePid = await getScreenPanePid(getFutureScreenName(canonicalScreenName));
308
+ if (!futurePid) return false;
326
309
  let futureScreenName = getFutureScreenName(canonicalScreenName);
327
- if (!await screenExists(futureScreenName)) return false;
328
310
  let prefix = getTmuxPrefix();
329
- if (await screenExists(canonicalScreenName)) {
311
+ let outgoingPid = await getScreenPanePid(canonicalScreenName);
312
+ if (outgoingPid) {
313
+ // Moved aside rather than waited on, so the canonical name is free at once and the old process winds down on its own
330
314
  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
315
  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
- }
316
+ await renameProcessScreen({ folder, panePid: outgoingPid, screenName: retiringScreenName });
317
+ if (replacedNodeId) {
318
+ // The file in the folder belongs to the replacement now, so only the registration goes
319
+ void removeServiceNode({ folder, nodeId: replacedNodeId, ownsFile: false });
339
320
  }
340
- // Detached: the canonical name is already free, so nothing waits on the old process winding down
341
- void killScreen({ screenName: retiringScreenName });
321
+ retiringScreens.add(retiringScreenName);
322
+ void killScreen({ screenName: retiringScreenName }).finally(() => {
323
+ retiringScreens.delete(retiringScreenName);
324
+ });
342
325
  }
343
- console.log(green(`Renaming future screen ${futureScreenName} to ${canonicalScreenName} (takeover complete)`));
326
+ console.log(green(`Renaming ${futureScreenName} to ${canonicalScreenName} (takeover complete)`));
344
327
  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
- }
328
+ await renameProcessScreen({ folder, panePid: futurePid, screenName: canonicalScreenName });
349
329
  return true;
350
330
  });
351
331
  }
352
332
 
333
+ /** Points a process's record at the session name it now runs under. The pid is the key, so this reads exactly one file. */
334
+ async function renameProcessScreen(config: { folder: string; panePid: string; screenName: string }): Promise<void> {
335
+ let startTime = await getProcessStartTime(config.panePid);
336
+ if (!startTime) return;
337
+ let record = await readProcessRecord(config.folder, config.panePid, startTime);
338
+ if (!record || record.screenName === config.screenName) return;
339
+ await writeProcessRecord({ ...record, screenName: config.screenName });
340
+ }
341
+
353
342
  // canonical screen name -> the takeover we already have scheduled for it
354
343
  const scheduledTakeovers = new Map<string, { time: number; cancel: () => void }>();
355
344
 
356
345
  /** 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;
346
+ export function scheduleTakeover(config: {
347
+ canonicalScreenName: string;
348
+ folder: string;
349
+ killTime: number;
350
+ /** The node the version being replaced registered as, read before the replacement overwrote the folder's file. */
351
+ replacedNodeId?: string;
352
+ }): void {
353
+ let { canonicalScreenName, folder, killTime, replacedNodeId } = config;
359
354
  let existing = scheduledTakeovers.get(canonicalScreenName);
360
355
  if (existing?.time === killTime) return;
361
356
  existing?.cancel();
@@ -364,12 +359,18 @@ export function scheduleTakeover(config: { canonicalScreenName: string; folder:
364
359
  time: killTime,
365
360
  callback: () => {
366
361
  scheduledTakeovers.delete(canonicalScreenName);
367
- void takeoverScreen({ canonicalScreenName, folder });
362
+ void takeoverScreen({ canonicalScreenName, folder, replacedNodeId });
368
363
  },
369
364
  });
370
365
  scheduledTakeovers.set(canonicalScreenName, { time: killTime, cancel });
371
366
  }
372
367
 
368
+ // 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.
369
+ const retiringScreens = new Set<string>();
370
+ export function isRetiring(screenName: string): boolean {
371
+ return retiringScreens.has(screenName);
372
+ }
373
+
373
374
  // 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
375
  export const killScreen = measureWrap(async function killScreen(config: {
375
376
  screenName: string;
@@ -385,10 +386,11 @@ export const killScreen = measureWrap(async function killScreen(config: {
385
386
  /** 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
387
  export async function streamProcessOutput(config: {
387
388
  folder: string;
388
- launchId: string;
389
+ pid: string;
390
+ startTime: number;
389
391
  onData: (data: string) => Promise<void>;
390
392
  }) {
391
- let logPath = getProcessLogPath(config.folder, config.launchId);
393
+ let logPath = getProcessLogPath(config.folder, config.pid, config.startTime);
392
394
  let serialOnData = runInSerial(config.onData);
393
395
  let stopped = false;
394
396
  let childProcess: ChildProcess | undefined;
@@ -407,14 +409,14 @@ export async function streamProcessOutput(config: {
407
409
  const onDataWrapped = async (data: string) => {
408
410
  pendingDataCalls++;
409
411
  if (pendingDataCalls > MAX_PENDING_CALLS) {
410
- console.error(`Too many queued onData calls for ${config.launchId}, stopping stream.`);
412
+ console.error(`Too many queued onData calls for ${config.pid}, stopping stream.`);
411
413
  await stop();
412
414
  return;
413
415
  }
414
416
  try {
415
417
  await serialOnData(data);
416
418
  } catch (e: any) {
417
- console.log(`Callback for stream output ${config.launchId} failed. It probably just disconnected, almost certainly not an error: ${e.message}`);
419
+ console.log(`Callback for stream output ${config.pid} failed. It probably just disconnected, almost certainly not an error: ${e.message}`);
418
420
  await stop();
419
421
  } finally {
420
422
  pendingDataCalls--;
@@ -422,7 +424,7 @@ export async function streamProcessOutput(config: {
422
424
  };
423
425
 
424
426
  try {
425
- let tailScript = getTailScriptPath(config.folder, config.launchId);
427
+ let tailScript = getTailScriptPath(config.folder, config.pid, config.startTime);
426
428
  await fs.promises.writeFile(tailScript, getLogTailScript(logPath));
427
429
  await runPromise(`chmod +x ${tailScript}`);
428
430