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