querysub 0.569.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.
@@ -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.
@@ -25,9 +25,11 @@ const SCREEN_SUFFIX = "-dply";
25
25
  export function getScreenName(config: { serviceKey: string; index: number }): string {
26
26
  return `${config.serviceKey}-${config.index}${SCREEN_SUFFIX}`.replace(/[^a-zA-Z0-9\-_]/g, "_");
27
27
  }
28
+ /** Marks a session that has already been handed off and is winding down on its own timer. */
29
+ export const RETIRING_MARKER = "-retiring-";
28
30
  /** The name an outgoing screen is moved to so the new process can take the canonical name at once, instead of anything waiting for the old process to wind down. Keeps the suffix, so it is still a screen we know about. */
29
31
  export function getRetiringScreenName(canonicalScreenName: string, at: number): string {
30
- return canonicalScreenName.slice(0, -SCREEN_SUFFIX.length) + `-retiring-${at}` + SCREEN_SUFFIX;
32
+ return canonicalScreenName.slice(0, -SCREEN_SUFFIX.length) + RETIRING_MARKER + at + SCREEN_SUFFIX;
31
33
  }
32
34
  // The new version's screen during a release, in the SAME folder as the canonical screen: created (just echoing when it will start) shortly before releaseTime, started at releaseTime, and renamed to the canonical screen name once the old screen is killed at releaseTime + overlapTime.
33
35
  export function getFutureScreenName(canonicalScreenName: string): string {
@@ -36,25 +38,29 @@ export function getFutureScreenName(canonicalScreenName: string): string {
36
38
 
37
39
 
38
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.
39
- const screenLocks = new Map<string, Promise<unknown>>();
40
- export function withScreenLock<T>(canonicalScreenName: string, run: () => Promise<T>): Promise<T> {
41
- let previous = screenLocks.get(canonicalScreenName) || Promise.resolve();
42
- let next = previous.then(run, run);
43
- // Failures release the lock rather than poisoning every later operation on this screen
44
- screenLocks.set(canonicalScreenName, next.then(() => { }, () => { }));
45
- 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;
46
44
  }
47
45
 
48
- /** Whether a session exists, without listing every session and its panes - the takeover already knows the exact names it cares about. */
49
- 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> {
50
48
  let prefix = getTmuxPrefix();
49
+ let output: string;
51
50
  try {
52
- await runPromise(`${prefix}tmux has-session -t ${screenName}`, { quiet: true });
53
- return true;
51
+ output = await runPromise(`${prefix}tmux list-panes -t ${screenName} -F "#{pane_pid}"`, { quiet: true });
54
52
  } catch {
55
- // has-session exits non-zero for "no such session", which is an answer, not a failure
56
- return false;
53
+ // Exits non-zero for "no such session", which is an answer, not a failure
54
+ return undefined;
57
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);
58
64
  }
59
65
 
60
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. */
@@ -213,94 +219,76 @@ export const runScreenCommand = measureWrap(async function runScreenCommand(conf
213
219
  command: string;
214
220
  // Defaults to the folder derived from screenName; a future screen passes its canonical screen's folder
215
221
  folder?: string;
216
- // Identifies the process this launch creates, so its log and its configuration are stored against it
217
- record: Omit<ProcessRecord, "launchId" | "folder" | "screenName" | "startTime">;
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">;
218
224
  }): Promise<string> {
219
225
  let prefix = getTmuxPrefix();
220
226
  let screenName = config.screenName;
221
227
 
222
- // 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.
223
- // 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.
224
- if ((await getScreenState(false)).some(x => x.screenName === screenName)) {
225
- 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`));
226
231
  await runPromise(`${prefix}tmux kill-session -t ${screenName}`);
227
232
  }
228
233
  await runPromise(`${prefix}tmux new -s ${screenName} -d`);
229
234
 
230
235
  let folder = config.folder || os.homedir() + "/" + SERVICE_FOLDER + screenName + "/";
231
236
 
232
- // 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.
233
- let outgoingNodeId = await readServiceNodeId(folder);
234
- if (outgoingNodeId) {
235
- for (let record of await listProcessRecords()) {
236
- if (record.folder !== folder || record.deadTime !== undefined || record.nodeId) continue;
237
- await writeProcessRecord({ ...record, nodeId: outgoingNodeId });
238
- }
239
- }
240
-
241
- // 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.
242
- let panePid = (await getScreenState(false)).find(x => x.screenName === screenName)?.pid;
243
- 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) {
244
240
  throw new Error(`Screen ${screenName} does not exist after creating it, so there is no process to run the command in`);
245
241
  }
246
- let startTime = await getProcessStartTime(panePid);
247
- let launchId = createLaunchId(panePid, startTime);
248
- await writeProcessRecord({
249
- ...config.record,
250
- launchId,
251
- folder,
252
- screenName,
253
- pid: parseInt(panePid) || undefined,
254
- startTime,
255
- });
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 });
256
246
  await runPromise(`${prefix}tmux send-keys -t ${screenName} 'cd ${folder}git' Enter`);
257
247
  let command = `#!/bin/bash
258
248
  ${config.command}
259
249
  `;
260
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
261
252
  await runPromise(`${prefix}tmux send-keys -t ${screenName} 'bash ../command.sh' Enter`);
262
-
263
- await setupPipePane({ screenName, folder, launchId });
264
- return launchId;
253
+ return pid;
265
254
  });
266
255
 
267
- /** 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.
268
257
  *
269
- * 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. */
270
259
  export async function ensureProcessRecord(config: {
271
260
  screenName: string;
272
261
  folder: string;
273
262
  panePid: string;
274
- record: Omit<ProcessRecord, "launchId" | "folder" | "screenName" | "startTime" | "pid">;
263
+ record: Omit<ProcessRecord, "pid" | "startTime" | "folder" | "screenName">;
275
264
  }): Promise<void> {
276
- let startTime = await getProcessStartTime(config.panePid);
265
+ let pid = config.panePid;
266
+ let startTime = await getProcessStartTime(pid);
277
267
  if (!startTime) return;
278
- let launchId = createLaunchId(config.panePid, startTime);
279
- let existing = (await listProcessRecords()).find(x => x.launchId === launchId && x.folder === config.folder);
268
+ let existing = await readProcessRecord(config.folder, pid, startTime);
280
269
  if (existing) {
281
270
  // A takeover renames the session, so the record follows the name it now runs under
282
271
  if (existing.screenName === config.screenName) return;
283
272
  await writeProcessRecord({ ...existing, screenName: config.screenName });
284
273
  return;
285
274
  }
286
- 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`);
287
276
  await writeProcessRecord({
288
277
  ...config.record,
289
- launchId,
278
+ pid,
279
+ startTime,
290
280
  folder: config.folder,
291
281
  screenName: config.screenName,
292
- pid: parseInt(config.panePid) || undefined,
293
- startTime,
294
282
  });
295
283
  // Its output was going to whatever the previous incarnation pointed at (or nowhere), so point it at its own log from here on
296
- await setupPipePane({ screenName: config.screenName, folder: config.folder, launchId });
284
+ await setupPipePane({ screenName: config.screenName, folder: config.folder, pid, startTime });
297
285
  }
298
286
 
299
- // Points the screen's pipe-pane at this launch's own log file.
300
- 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 }) {
301
289
  let prefix = getTmuxPrefix();
302
- let logPath = getProcessLogPath(config.folder, config.launchId);
303
- 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);
304
292
  await fs.promises.mkdir(path.dirname(logPath), { recursive: true });
305
293
  await fs.promises.writeFile(pipeScript, getLogPipeScript(logPath));
306
294
  await runPromise(`chmod +x ${pipeScript}`);
@@ -308,42 +296,61 @@ async function setupPipePane(config: { screenName: string; folder: string; launc
308
296
  }
309
297
 
310
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. */
311
- export async function takeoverScreen(config: { canonicalScreenName: string; folder: string }): Promise<boolean> {
312
- 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;
313
306
  return await withScreenLock(canonicalScreenName, async () => {
307
+ let futurePid = await getScreenPanePid(getFutureScreenName(canonicalScreenName));
308
+ if (!futurePid) return false;
314
309
  let futureScreenName = getFutureScreenName(canonicalScreenName);
315
- if (!await screenExists(futureScreenName)) return false;
316
310
  let prefix = getTmuxPrefix();
317
- 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
318
314
  let retiringScreenName = getRetiringScreenName(canonicalScreenName, Date.now());
319
- // 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
320
- let outgoing = await findRunningRecord(folder, canonicalScreenName);
321
315
  await runPromise(`${prefix}tmux rename-session -t ${canonicalScreenName} ${retiringScreenName}`);
322
- if (outgoing) {
323
- await writeProcessRecord({ ...outgoing, screenName: retiringScreenName });
324
- if (outgoing.nodeId) {
325
- void removeServiceNode({ folder, nodeId: outgoing.nodeId, ownsFile: false });
326
- }
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 });
327
320
  }
328
- // Detached: the canonical name is already free, so nothing waits on the old process winding down
329
- void killScreen({ screenName: retiringScreenName });
321
+ retiringScreens.add(retiringScreenName);
322
+ void killScreen({ screenName: retiringScreenName }).finally(() => {
323
+ retiringScreens.delete(retiringScreenName);
324
+ });
330
325
  }
331
- console.log(green(`Renaming future screen ${futureScreenName} to ${canonicalScreenName} (takeover complete)`));
326
+ console.log(green(`Renaming ${futureScreenName} to ${canonicalScreenName} (takeover complete)`));
332
327
  await runPromise(`${prefix}tmux rename-session -t ${futureScreenName} ${canonicalScreenName}`);
333
- let incoming = await findRunningRecord(folder, futureScreenName);
334
- if (incoming) {
335
- await writeProcessRecord({ ...incoming, screenName: canonicalScreenName });
336
- }
328
+ await renameProcessScreen({ folder, panePid: futurePid, screenName: canonicalScreenName });
337
329
  return true;
338
330
  });
339
331
  }
340
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
+
341
342
  // canonical screen name -> the takeover we already have scheduled for it
342
343
  const scheduledTakeovers = new Map<string, { time: number; cancel: () => void }>();
343
344
 
344
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. */
345
- export function scheduleTakeover(config: { canonicalScreenName: string; folder: string; killTime: number }): void {
346
- 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;
347
354
  let existing = scheduledTakeovers.get(canonicalScreenName);
348
355
  if (existing?.time === killTime) return;
349
356
  existing?.cancel();
@@ -352,12 +359,18 @@ export function scheduleTakeover(config: { canonicalScreenName: string; folder:
352
359
  time: killTime,
353
360
  callback: () => {
354
361
  scheduledTakeovers.delete(canonicalScreenName);
355
- void takeoverScreen({ canonicalScreenName, folder });
362
+ void takeoverScreen({ canonicalScreenName, folder, replacedNodeId });
356
363
  },
357
364
  });
358
365
  scheduledTakeovers.set(canonicalScreenName, { time: killTime, cancel });
359
366
  }
360
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
+
361
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.
362
375
  export const killScreen = measureWrap(async function killScreen(config: {
363
376
  screenName: string;
@@ -373,10 +386,11 @@ export const killScreen = measureWrap(async function killScreen(config: {
373
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. */
374
387
  export async function streamProcessOutput(config: {
375
388
  folder: string;
376
- launchId: string;
389
+ pid: string;
390
+ startTime: number;
377
391
  onData: (data: string) => Promise<void>;
378
392
  }) {
379
- let logPath = getProcessLogPath(config.folder, config.launchId);
393
+ let logPath = getProcessLogPath(config.folder, config.pid, config.startTime);
380
394
  let serialOnData = runInSerial(config.onData);
381
395
  let stopped = false;
382
396
  let childProcess: ChildProcess | undefined;
@@ -395,14 +409,14 @@ export async function streamProcessOutput(config: {
395
409
  const onDataWrapped = async (data: string) => {
396
410
  pendingDataCalls++;
397
411
  if (pendingDataCalls > MAX_PENDING_CALLS) {
398
- 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.`);
399
413
  await stop();
400
414
  return;
401
415
  }
402
416
  try {
403
417
  await serialOnData(data);
404
418
  } catch (e: any) {
405
- 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}`);
406
420
  await stop();
407
421
  } finally {
408
422
  pendingDataCalls--;
@@ -410,7 +424,7 @@ export async function streamProcessOutput(config: {
410
424
  };
411
425
 
412
426
  try {
413
- let tailScript = getTailScriptPath(config.folder, config.launchId);
427
+ let tailScript = getTailScriptPath(config.folder, config.pid, config.startTime);
414
428
  await fs.promises.writeFile(tailScript, getLogTailScript(logPath));
415
429
  await runPromise(`chmod +x ${tailScript}`);
416
430