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