@edspencer/paddock 0.80.0 → 0.82.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/package.json +1 -1
- package/packages/server/dist/app.d.ts +7 -0
- package/packages/server/dist/app.js +1 -1
- package/packages/server/dist/auth.d.ts +3 -2
- package/packages/server/dist/auth.js +11 -0
- package/packages/server/dist/background-live.d.ts +17 -0
- package/packages/server/dist/background-live.js +24 -0
- package/packages/server/dist/busy.d.ts +96 -0
- package/packages/server/dist/busy.js +88 -0
- package/packages/server/dist/cli/args.d.ts +70 -29
- package/packages/server/dist/cli/args.js +306 -7
- package/packages/server/dist/cli/busy-command.d.ts +58 -0
- package/packages/server/dist/cli/busy-command.js +108 -0
- package/packages/server/dist/cli/busy.d.ts +69 -0
- package/packages/server/dist/cli/busy.js +150 -0
- package/packages/server/dist/cli/output.d.ts +52 -0
- package/packages/server/dist/cli/output.js +84 -0
- package/packages/server/dist/cli/paddock.js +65 -21
- package/packages/server/dist/cli/service/index.d.ts +64 -0
- package/packages/server/dist/cli/service/index.js +208 -7
- package/packages/server/dist/cli/service/launchd.d.ts +15 -1
- package/packages/server/dist/cli/service/launchd.js +116 -18
- package/packages/server/dist/cli/service/ready.d.ts +30 -0
- package/packages/server/dist/cli/service/ready.js +35 -0
- package/packages/server/dist/cli/service/systemd.js +27 -0
- package/packages/server/dist/cli/update/install-method.d.ts +131 -0
- package/packages/server/dist/cli/update/install-method.js +258 -0
- package/packages/server/dist/cli/update/lock.d.ts +40 -0
- package/packages/server/dist/cli/update/lock.js +108 -0
- package/packages/server/dist/cli/update/package-manager.d.ts +109 -0
- package/packages/server/dist/cli/update/package-manager.js +221 -0
- package/packages/server/dist/cli/update/registry.d.ts +89 -0
- package/packages/server/dist/cli/update/registry.js +145 -0
- package/packages/server/dist/cli/update/semver.d.ts +68 -0
- package/packages/server/dist/cli/update/semver.js +232 -0
- package/packages/server/dist/cli/update/update-check.d.ts +80 -0
- package/packages/server/dist/cli/update/update-check.js +116 -0
- package/packages/server/dist/cli/update/update.d.ts +160 -0
- package/packages/server/dist/cli/update/update.js +609 -0
- package/packages/server/dist/cli/update-command.d.ts +20 -0
- package/packages/server/dist/cli/update-command.js +113 -0
- package/packages/server/dist/herdctl.d.ts +19 -0
- package/packages/server/dist/herdctl.js +26 -0
- package/packages/server/dist/routes/meta.js +45 -0
- package/packages/server/dist/runtime-file.d.ts +26 -0
- package/packages/server/dist/runtime-file.js +91 -0
- package/packages/server/dist/start.d.ts +33 -0
- package/packages/server/dist/start.js +115 -8
- package/packages/server/dist/ws-context.d.ts +9 -0
- package/packages/server/dist/ws-turn.js +1 -0
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{DeleteProjectDialog-DHczLhwQ.js → DeleteProjectDialog-CphdGB3R.js} +1 -1
- package/packages/web/dist/assets/DiscoverPage-C4OPLNO8.js +2 -0
- package/packages/web/dist/assets/{DiscoverView-DtTOpTji.js → DiscoverView-B_kcF4sm.js} +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-BRoBh1C1.js → InstanceConfigPage-CvUK5DKW.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-DJf8IJuk.js → MarkdownRenderer-DvZ-j_Ao.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-EioguUKW.js → ProjectRedirect-DayHvUH2.js} +1 -1
- package/packages/web/dist/assets/{ProjectView-BiKnGfk6.js → ProjectView-CCw83gUi.js} +3 -3
- package/packages/web/dist/assets/{ProjectsGrid-D6kfaIal.js → ProjectsGrid-B3Klg2H4.js} +1 -1
- package/packages/web/dist/assets/{RootHome-DpcyuV6e.js → RootHome-BxcDvpgI.js} +1 -1
- package/packages/web/dist/assets/{Surfaces-CHM29PhP.js → Surfaces-7UlhFBSG.js} +1 -1
- package/packages/web/dist/assets/{arc-fiqD05Hu.js → arc-DNkh5o5Q.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-BuuB2Nkf.js → architectureDiagram-3BPJPVTR-BrgN3oLF.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DQMBuzfi.js → blockDiagram-GPEHLZMM-D05XOJSk.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-BKN0UxCH.js → c4Diagram-AAUBKEIU-CKaMBhMP.js} +1 -1
- package/packages/web/dist/assets/channel-JrEedxB8.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-DW37EupX.js → chunk-2J33WTMH-8hgadCQc.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-Dyv7zV_c.js → chunk-4BX2VUAB-CBfEC-pu.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-DzMR0dOk.js → chunk-55IACEB6-DV1eMgHk.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-BdZ3Sor8.js → chunk-727SXJPM-Cfgl5fxY.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-B4i7_og0.js → chunk-AQP2D5EJ-BXhqD295.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-jcTr85it.js → chunk-FMBD7UC4-TFGOPtek.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-fgMsadqB.js → chunk-ND2GUHAM-CHFM665R.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-bJNHYS1W.js → chunk-QZHKN3VN-CqEf6MMT.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CHJiQp-W.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CHJiQp-W.js +2 -0
- package/packages/web/dist/assets/{core-Bk4KFrmN.js → core-DmEaKxBb.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BIqCSlQy.js → cose-bilkent-S5V4N54A-BnGI6jkr.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-6nyhtVQz.js → dagre-BM42HDAG-CKoPx61E.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-CjizfipR.js → diagram-2AECGRRQ-mMmtlBl1.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-DlgB5vWb.js → diagram-5GNKFQAL-Cdz9Mtnt.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-CNC9qanV.js → diagram-KO2AKTUF-DQxM3wYj.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-DSJ-s9lh.js → diagram-LMA3HP47-BWrinPsq.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-C9FoHBDg.js → diagram-OG6HWLK6-U6MYUve2.js} +1 -1
- package/packages/web/dist/assets/{documentTitle-Cj_AY97j.js → documentTitle-DGjRXuTP.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-z6gku4Yt.js → erDiagram-TEJ5UH35-xqMCsXxY.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-Dl5c_FgU.js → flowDiagram-I6XJVG4X-PMfy_czF.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CIscJ-Dt.js → ganttDiagram-6RSMTGT7-whhYYE4N.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-CvttOXM_.js → gitGraphDiagram-PVQCEYII-C51JW4tf.js} +1 -1
- package/packages/web/dist/assets/{index-DN82do6Z.js → index-CHOtvlnO.js} +2 -2
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-dzWYdDJj.js → infoDiagram-5YYISTIA-SH8cr905.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-8yX9k4bo.js → ishikawaDiagram-YF4QCWOH-BjqaoU3U.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BmitzJDR.js → journeyDiagram-JHISSGLW-CBmdEVrd.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BZmIkfkW.js → kanban-definition-UN3LZRKU-U8njjMLZ.js} +1 -1
- package/packages/web/dist/assets/{linear-BBLXE4o7.js → linear-DF66GPs1.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-mSKff4yu.js → mermaid.core-CouJz7Ph.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BWAUa4CP.js → mindmap-definition-RKZ34NQL-BYIf0oFt.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-VmVdiL4C.js → pieDiagram-4H26LBE5-Cm8rgg09.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Dybfv6NU.js → quadrantDiagram-W4KKPZXB-C3Il5kOH.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-BPnOvB7w.js → requirementDiagram-4Y6WPE33-D3PE2nTe.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-YXIEmKw7.js → sankeyDiagram-5OEKKPKP-DdmvBM18.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DintIRUe.js → sequenceDiagram-3UESZ5HK-B8M_GAIq.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-eMsjahWp.js → stateDiagram-AJRCARHV-DZkXnK1_.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CjCdT9Ls.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-as4zRCPn.js → timeline-definition-PNZ67QCA-IHXq7rqi.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CPzvUkM0.js → vennDiagram-CIIHVFJN-Dga7tLtT.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-q6moXj5g.js → wardley-L42UT6IY-CmcwnEJ-.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-BsEEb9Gm.js → wardleyDiagram-YWT4CUSO-zHuSLrD4.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-DSgZlcZW.js → xychartDiagram-2RQKCTM6-D93MU_ic.js} +1 -1
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/DiscoverPage-BSGSzTEs.js +0 -2
- package/packages/web/dist/assets/channel-B7yh6Su_.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DIlhMPgi.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DIlhMPgi.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CpAOGzbO.js +0 -2
|
@@ -33,6 +33,8 @@ import { createLaunchdBackend } from "./launchd.js";
|
|
|
33
33
|
import { createSystemdBackend } from "./systemd.js";
|
|
34
34
|
import { missingPaths } from "./path-env.js";
|
|
35
35
|
import { DEFAULT_LOG_LINES, followFile, sizeOf, tailFile } from "./logs.js";
|
|
36
|
+
import { readLiveRuntimeFile } from "../../runtime-file.js";
|
|
37
|
+
import { updateSummary } from "../update/update-check.js";
|
|
36
38
|
import { waitForReady, fetchHealth, READY_TIMEOUT_LABEL, } from "./ready.js";
|
|
37
39
|
/**
|
|
38
40
|
* Pick a backend, or explain why there isn't one.
|
|
@@ -63,8 +65,52 @@ function resolveDataDir(opts, ctx) {
|
|
|
63
65
|
}
|
|
64
66
|
return { dataDir: defaultDataDir(ctx.homeDir), explicit: undefined };
|
|
65
67
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
/** The value of a flag in a recorded argument vector, or undefined. */
|
|
69
|
+
function argValue(argv, names) {
|
|
70
|
+
for (let i = 0; i < argv.length; i++) {
|
|
71
|
+
if (names.includes(argv[i]) && argv[i + 1] !== undefined)
|
|
72
|
+
return argv[i + 1];
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The settings an EXISTING unit carries, as if they had been typed
|
|
78
|
+
* (`service install --refresh`, SPEC §1 and §5 step 11).
|
|
79
|
+
*
|
|
80
|
+
* This is what makes a reinstall idempotent rather than a reset. A unit
|
|
81
|
+
* installed with `--port 9000 --data-dir /srv/paddock` must come back on 9000
|
|
82
|
+
* pointing at /srv/paddock — and `update` is precisely the caller that has no
|
|
83
|
+
* way to know those values, because it runs the NEW version's CLI in a shell
|
|
84
|
+
* that never typed them.
|
|
85
|
+
*
|
|
86
|
+
* Flags still win, so `service install --refresh --port 7300` moves the port.
|
|
87
|
+
* The ENVIRONMENT deliberately does not: `resolveDataDir` normally lets
|
|
88
|
+
* `PADDOCK_DATA_DIR` stand in for `--data-dir`, which would be wrong here —
|
|
89
|
+
* the refresh is a faithful re-render of a unit, and inheriting the updating
|
|
90
|
+
* shell's data dir would silently repoint somebody's service at a different
|
|
91
|
+
* instance. A unit with no recorded `--data-dir` keeps having none, which is
|
|
92
|
+
* how it stays the same `~/.paddock` a bare `paddock` reaches.
|
|
93
|
+
*/
|
|
94
|
+
function inherit(opts, state) {
|
|
95
|
+
const port = argValue(state.argv, ["--port", "-p"]);
|
|
96
|
+
const host = argValue(state.argv, ["--host"]);
|
|
97
|
+
const dataDir = argValue(state.argv, ["--data-dir", "-d"]);
|
|
98
|
+
return {
|
|
99
|
+
...opts,
|
|
100
|
+
...(opts.port === undefined && port !== undefined ? { port } : {}),
|
|
101
|
+
...(opts.host === undefined && host !== undefined ? { host } : {}),
|
|
102
|
+
...(opts.dataDir === undefined && dataDir !== undefined ? { dataDir } : {}),
|
|
103
|
+
verbose: opts.verbose || state.argv.includes("--verbose"),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function specFor(opts, ctx,
|
|
107
|
+
/** Set on a `--refresh`: the data dir comes from the unit, not the environment. */
|
|
108
|
+
fromUnit = false) {
|
|
109
|
+
const { dataDir, explicit } = fromUnit
|
|
110
|
+
? opts.dataDir !== undefined
|
|
111
|
+
? { dataDir: path.resolve(opts.dataDir), explicit: path.resolve(opts.dataDir) }
|
|
112
|
+
: { dataDir: defaultDataDir(ctx.homeDir), explicit: undefined }
|
|
113
|
+
: resolveDataDir(opts, ctx);
|
|
68
114
|
const spec = buildSpec({
|
|
69
115
|
nodePath: ctx.nodePath,
|
|
70
116
|
scriptPath: ctx.scriptPath,
|
|
@@ -136,8 +182,27 @@ function lingerNote(linger) {
|
|
|
136
182
|
async function install(opts, ctx, backend) {
|
|
137
183
|
if (isNpxPath(ctx.packageRoot))
|
|
138
184
|
throw new CliError(NPX_REFUSAL);
|
|
139
|
-
|
|
185
|
+
// `--refresh` (SPEC §5 step 11): re-render an EXISTING unit, keeping what it
|
|
186
|
+
// records. With nothing installed there is nothing to refresh — exit 4, the
|
|
187
|
+
// same code every other command that needs a unit returns, rather than
|
|
188
|
+
// quietly performing a first install from defaults. That distinction is the
|
|
189
|
+
// whole reason the flag exists: `update` runs this through the new version's
|
|
190
|
+
// CLI, and a silent fallback would move a `--port 9000` service to 7233 on
|
|
191
|
+
// the one run nobody is watching.
|
|
192
|
+
const refreshing = opts.refresh === true;
|
|
193
|
+
let existing;
|
|
194
|
+
if (refreshing) {
|
|
195
|
+
existing = backend.status();
|
|
196
|
+
if (!existing.registered) {
|
|
197
|
+
console.log(notInstalled(backend));
|
|
198
|
+
return EXIT.notInstalled;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const { spec, dataDir } = specFor(existing !== undefined ? inherit(opts, existing) : opts, ctx, refreshing);
|
|
140
202
|
const url = `http://${hostFromArgv(spec.args)}:${portFromArgv(spec.args)}`;
|
|
203
|
+
if (refreshing) {
|
|
204
|
+
console.log(`\n Re-writing ${backend.unitPath}\n Keeping: paddock ${spec.args.join(" ")}`);
|
|
205
|
+
}
|
|
141
206
|
console.log(installProgress(backend));
|
|
142
207
|
backend.install(spec);
|
|
143
208
|
// The unit is written and the supervisor has accepted it. That is NOT the
|
|
@@ -251,6 +316,11 @@ export function installedVersionOf(cliPath) {
|
|
|
251
316
|
dir = parent;
|
|
252
317
|
}
|
|
253
318
|
}
|
|
319
|
+
/** `{ update }` when there is a recent check to repeat, and `{}` otherwise. */
|
|
320
|
+
function withUpdate(home) {
|
|
321
|
+
const summary = updateSummary(home);
|
|
322
|
+
return summary !== undefined ? { update: summary } : {};
|
|
323
|
+
}
|
|
254
324
|
async function buildStatus(ctx, backend) {
|
|
255
325
|
const state = backend.status();
|
|
256
326
|
const base = {
|
|
@@ -277,6 +347,7 @@ async function buildStatus(ctx, backend) {
|
|
|
277
347
|
node: null,
|
|
278
348
|
cli: null,
|
|
279
349
|
linger: lingerValue,
|
|
350
|
+
...withUpdate(path.resolve(ctx.envDataDir ?? defaultDataDir(ctx.homeDir))),
|
|
280
351
|
warnings: [],
|
|
281
352
|
};
|
|
282
353
|
}
|
|
@@ -318,6 +389,7 @@ async function buildStatus(ctx, backend) {
|
|
|
318
389
|
node: state.nodePath ?? null,
|
|
319
390
|
cli: state.cliPath ?? null,
|
|
320
391
|
linger: lingerValue,
|
|
392
|
+
...withUpdate(dataDir),
|
|
321
393
|
warnings,
|
|
322
394
|
};
|
|
323
395
|
}
|
|
@@ -354,6 +426,12 @@ async function status(opts, ctx, backend) {
|
|
|
354
426
|
" paddock service install register it and start it now",
|
|
355
427
|
"",
|
|
356
428
|
].join("\n"));
|
|
429
|
+
// The notice belongs here too. Found in QA: it was printed only on the
|
|
430
|
+
// installed path, so a machine with no service got `update` in `--json`
|
|
431
|
+
// and silence in the prose — the exact `--json`-disagrees-with-the-text rot
|
|
432
|
+
// that one StatusReport is supposed to prevent. A newer version is worth
|
|
433
|
+
// knowing about whether or not a unit exists.
|
|
434
|
+
noteUpdate(report);
|
|
357
435
|
return EXIT.notInstalled;
|
|
358
436
|
}
|
|
359
437
|
const versions = report.runningVersion !== null && report.runningVersion !== report.installedVersion
|
|
@@ -387,8 +465,33 @@ async function status(opts, ctx, backend) {
|
|
|
387
465
|
...report.warnings.flatMap((w) => ["", ` ⚠ ${w}`]),
|
|
388
466
|
"",
|
|
389
467
|
].join("\n"));
|
|
468
|
+
// The update notice goes to STDERR, and only ever repeats the cache (SPEC
|
|
469
|
+
// §3). stderr because it is prose about something other than what was asked
|
|
470
|
+
// for, and because that keeps every byte of stdout under the `--json`
|
|
471
|
+
// discipline's one rule rather than two.
|
|
472
|
+
noteUpdate(report);
|
|
390
473
|
return statusExit(report);
|
|
391
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* Repeat the cached update answer on stderr, when there is one worth repeating
|
|
477
|
+
* (SPEC §3).
|
|
478
|
+
*
|
|
479
|
+
* Nothing is fetched here. The `Run:` line is the plain `paddock update`
|
|
480
|
+
* rather than §6's per-install variant, because working that out needs the
|
|
481
|
+
* install method and the target's tag — and `status` has deliberately not
|
|
482
|
+
* asked the registry, so it has neither. Anyone whose install cannot update
|
|
483
|
+
* itself gets the right command from `update --check`, which has.
|
|
484
|
+
*/
|
|
485
|
+
function noteUpdate(report) {
|
|
486
|
+
const update = report.update;
|
|
487
|
+
if (update === undefined || !update.updateAvailable)
|
|
488
|
+
return;
|
|
489
|
+
console.error("");
|
|
490
|
+
const have = report.runningVersion ?? report.installedVersion;
|
|
491
|
+
console.error(` ${update.latest} is available${have !== null && have !== undefined ? ` (you have ${have})` : ""}.`);
|
|
492
|
+
console.error(" Run: paddock update");
|
|
493
|
+
console.error(` Checked: ${update.checkedAt}`);
|
|
494
|
+
}
|
|
392
495
|
/** The exit code a report means (SPEC §2). */
|
|
393
496
|
function statusExit(report) {
|
|
394
497
|
if (!report.installed)
|
|
@@ -432,7 +535,7 @@ function notInstalled(backend) {
|
|
|
432
535
|
* `ThrottleInterval`, and `launchctl print` says `running` for most of that
|
|
433
536
|
* window). The wait is what makes the last line true.
|
|
434
537
|
*/
|
|
435
|
-
async function startOrRestart(action, ctx, backend) {
|
|
538
|
+
async function startOrRestart(action, opts, ctx, backend) {
|
|
436
539
|
const before = backend.status();
|
|
437
540
|
if (!before.registered) {
|
|
438
541
|
console.log(notInstalled(backend));
|
|
@@ -443,6 +546,14 @@ async function startOrRestart(action, ctx, backend) {
|
|
|
443
546
|
console.log(`\n Paddock is already running${pid(before)}.\n\n URL: ${url}\n`);
|
|
444
547
|
return EXIT.ok;
|
|
445
548
|
}
|
|
549
|
+
// A restart stops the instance first, so it asks the same question `stop`
|
|
550
|
+
// asks (SPEC §1): refuse with 75 unless `--force`. `start` does not — there
|
|
551
|
+
// is nothing running for it to cut short, and asking would be theatre.
|
|
552
|
+
if (action === "restart") {
|
|
553
|
+
const refused = await busyCheck(action, opts, ctx, before);
|
|
554
|
+
if (refused !== null)
|
|
555
|
+
return refused;
|
|
556
|
+
}
|
|
446
557
|
console.log(`\n ${action === "start" ? "Starting" : "Restarting"} Paddock…`);
|
|
447
558
|
if (action === "start")
|
|
448
559
|
backend.start();
|
|
@@ -469,8 +580,26 @@ async function startOrRestart(action, ctx, backend) {
|
|
|
469
580
|
// caller — often an Ansible task — has no other way to find out.
|
|
470
581
|
return readiness === "ready" ? EXIT.ok : EXIT.failure;
|
|
471
582
|
}
|
|
583
|
+
/**
|
|
584
|
+
* The busy check, asked of the instance the UNIT describes (SPEC §1, §4).
|
|
585
|
+
*
|
|
586
|
+
* `home` is `dataDirOf(state, ctx)` — the unit's own data dir, not whatever
|
|
587
|
+
* `--data-dir` this invocation carries. Same rule as `status` and `logs`
|
|
588
|
+
* (#824): the instance about to be stopped is the one the unit points at, and
|
|
589
|
+
* reading `runtime.json` from somewhere else would ask a different Paddock
|
|
590
|
+
* whether this one is busy.
|
|
591
|
+
*
|
|
592
|
+
* Returns null to go ahead, or the code to return (75).
|
|
593
|
+
*/
|
|
594
|
+
async function busyCheck(action, opts, ctx, state) {
|
|
595
|
+
const guard = ctx.guardBusy ?? (await import("../busy-command.js")).guardServiceBusy;
|
|
596
|
+
return guard(action, dataDirOf(state, ctx), {
|
|
597
|
+
...(opts.force === true ? { force: true } : {}),
|
|
598
|
+
...(opts.json === true ? { json: true } : {}),
|
|
599
|
+
});
|
|
600
|
+
}
|
|
472
601
|
/** `stop` (#873): put it down, leave it installed. */
|
|
473
|
-
function stop(backend) {
|
|
602
|
+
async function stop(opts, ctx, backend) {
|
|
474
603
|
const state = backend.status();
|
|
475
604
|
if (!state.registered) {
|
|
476
605
|
console.log(notInstalled(backend));
|
|
@@ -480,6 +609,12 @@ function stop(backend) {
|
|
|
480
609
|
console.log(`\n Paddock is already stopped.\n Unit: ${backend.unitPath}\n`);
|
|
481
610
|
return EXIT.ok;
|
|
482
611
|
}
|
|
612
|
+
// Refuse with 75 if it is in the middle of something, unless --force (SPEC
|
|
613
|
+
// §4). Deliberately AFTER the two cases above: an unit that is not there, or
|
|
614
|
+
// a service already stopped, has no instance to ask and no work to cut short.
|
|
615
|
+
const refused = await busyCheck("stop", opts, ctx, state);
|
|
616
|
+
if (refused !== null)
|
|
617
|
+
return refused;
|
|
483
618
|
backend.stop();
|
|
484
619
|
console.log([
|
|
485
620
|
"",
|
|
@@ -542,19 +677,85 @@ async function logs(opts, ctx, backend) {
|
|
|
542
677
|
* never `process.exit()`, which truncates piped output mid-write.
|
|
543
678
|
*/
|
|
544
679
|
export async function runService(action, opts, ctx) {
|
|
680
|
+
// `--force` is rejected on every action but `stop` and `restart` — in
|
|
681
|
+
// `parseCommand`, once, for every verb. See the note there.
|
|
545
682
|
const backend = backendFor(ctx);
|
|
546
683
|
if (action === "install")
|
|
547
684
|
return install(opts, ctx, backend);
|
|
548
685
|
if (action === "uninstall")
|
|
549
686
|
return uninstall(ctx, backend);
|
|
550
687
|
if (action === "start" || action === "restart")
|
|
551
|
-
return startOrRestart(action, ctx, backend);
|
|
688
|
+
return startOrRestart(action, opts, ctx, backend);
|
|
552
689
|
if (action === "stop")
|
|
553
|
-
return stop(backend);
|
|
690
|
+
return stop(opts, ctx, backend);
|
|
554
691
|
if (action === "logs")
|
|
555
692
|
return logs(opts, ctx, backend);
|
|
556
693
|
return status(opts, ctx, backend);
|
|
557
694
|
}
|
|
695
|
+
/**
|
|
696
|
+
* What `paddock update` needs to know and do about the service (SPEC §5).
|
|
697
|
+
*
|
|
698
|
+
* Built here rather than in the update module so that everything which reads a
|
|
699
|
+
* unit file stays in one place: `update` gets the same answer `status` and
|
|
700
|
+
* `logs` get, from the same backend, including the #824 rule that the instance
|
|
701
|
+
* in question is the one the UNIT describes and not whatever `--data-dir` the
|
|
702
|
+
* invocation carries.
|
|
703
|
+
*
|
|
704
|
+
* Returns `home` too, which is the data dir `update.lock` and
|
|
705
|
+
* `update-check.json` go in. It has to be the unit's: the cache's reader is
|
|
706
|
+
* `service status`, which looks in the unit's data dir, so writing it anywhere
|
|
707
|
+
* else would be writing a file nothing reads.
|
|
708
|
+
*/
|
|
709
|
+
export function updateServiceHooks(ctx) {
|
|
710
|
+
const backend = backendFor(ctx);
|
|
711
|
+
const first = backend.status();
|
|
712
|
+
const home = first.registered
|
|
713
|
+
? dataDirOf(first, ctx)
|
|
714
|
+
: path.resolve(ctx.envDataDir ?? defaultDataDir(ctx.homeDir));
|
|
715
|
+
const inspect = () => {
|
|
716
|
+
const state = backend.status();
|
|
717
|
+
if (!state.registered) {
|
|
718
|
+
// No unit. A foreground `paddock serve` is still worth finding, because
|
|
719
|
+
// SPEC §5's "without a service" path has to be able to say "restart
|
|
720
|
+
// Paddock to use the new version" — and `runtime.json` is the only honest
|
|
721
|
+
// way to find it (no guessing at a default port, the busy client's rule).
|
|
722
|
+
const live = readLiveRuntimeFile(home);
|
|
723
|
+
return {
|
|
724
|
+
installed: false,
|
|
725
|
+
running: live !== null,
|
|
726
|
+
...(live !== null ? { url: live.url } : {}),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
return {
|
|
730
|
+
installed: true,
|
|
731
|
+
running: state.running,
|
|
732
|
+
url: urlOf(state),
|
|
733
|
+
log: logPathOf(state, dataDirOf(state, ctx)),
|
|
734
|
+
};
|
|
735
|
+
};
|
|
736
|
+
return {
|
|
737
|
+
installed: first.registered,
|
|
738
|
+
home,
|
|
739
|
+
hooks: {
|
|
740
|
+
inspect,
|
|
741
|
+
stop: () => backend.stop(),
|
|
742
|
+
// The NEW version's CLI writes the unit (SPEC §5 step 11). Run with the
|
|
743
|
+
// interpreter we are running, not the bin shim: the shim's shebang has to
|
|
744
|
+
// find `node` on PATH, and under a version manager there may not be one.
|
|
745
|
+
refresh: (cliPath) => {
|
|
746
|
+
const run = ctx.run ?? spawnRunner;
|
|
747
|
+
return run(process.execPath, [cliPath, "service", "install", "--refresh"]);
|
|
748
|
+
},
|
|
749
|
+
tail: (lines) => {
|
|
750
|
+
const state = backend.status();
|
|
751
|
+
if (!state.registered)
|
|
752
|
+
return "";
|
|
753
|
+
const file = logPathOf(state, dataDirOf(state, ctx));
|
|
754
|
+
return fs.existsSync(file) ? tailFile(file, lines) : "";
|
|
755
|
+
},
|
|
756
|
+
},
|
|
757
|
+
};
|
|
758
|
+
}
|
|
558
759
|
/** Exported for the entrypoint's context assembly, and for tests. */
|
|
559
760
|
export { serviceDir, isNpxPath };
|
|
560
761
|
/** Best-effort home dir; `os.homedir()` throws on a machine with no passwd entry. */
|
|
@@ -75,4 +75,18 @@ export declare function parseLaunchctlPrint(out: string): {
|
|
|
75
75
|
running: boolean;
|
|
76
76
|
pid?: string;
|
|
77
77
|
};
|
|
78
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Block this thread for `ms`.
|
|
80
|
+
*
|
|
81
|
+
* The backend's {@link Runner} is synchronous (`execFileSync`) and so is every
|
|
82
|
+
* `ServiceBackend` method, so the wait has to be too — making `install` async
|
|
83
|
+
* would ripple through the whole service CLI for a sleep. `Atomics.wait` on a
|
|
84
|
+
* throwaway buffer is the built-in way to do it; `setTimeout` cannot help here
|
|
85
|
+
* and spinning on `Date.now()` would burn a core.
|
|
86
|
+
*
|
|
87
|
+
* Injectable because the unit tests drive the retry path with a fake runner
|
|
88
|
+
* that fails every attempt: with the real sleep, pinning the retry count would
|
|
89
|
+
* cost three seconds per case.
|
|
90
|
+
*/
|
|
91
|
+
export declare function sleepSync(ms: number): void;
|
|
92
|
+
export declare function createLaunchdBackend(run: Runner, homeDir?: string, sleep?: (ms: number) => void): ServiceBackend;
|
|
@@ -188,8 +188,107 @@ function target() {
|
|
|
188
188
|
function domain() {
|
|
189
189
|
return `gui/${process.getuid?.() ?? 0}`;
|
|
190
190
|
}
|
|
191
|
-
|
|
191
|
+
/**
|
|
192
|
+
* ## The bootout/bootstrap race (#984)
|
|
193
|
+
*
|
|
194
|
+
* `launchctl bootout` returns as soon as launchd has ACCEPTED the request, not
|
|
195
|
+
* once the job has gone. Every sequence below that boots out and then
|
|
196
|
+
* bootstraps the same label therefore races launchd's own teardown, and when it
|
|
197
|
+
* loses, `bootstrap` fails with:
|
|
198
|
+
*
|
|
199
|
+
* ```
|
|
200
|
+
* launchctl bootstrap failed (exit 5): Input/output error
|
|
201
|
+
* ```
|
|
202
|
+
*
|
|
203
|
+
* Exit 5 is `EIO`, which is launchd's generic "I cannot do that right now" and
|
|
204
|
+
* the classic symptom of this race. It is timing-dependent, so it passes
|
|
205
|
+
* locally and on a PR and then fails on a release: Paddock 0.80.0's first
|
|
206
|
+
* release run failed exactly here, in CI's real-launchd job, on
|
|
207
|
+
* `paddock service restart`.
|
|
208
|
+
*
|
|
209
|
+
* Both halves of the fix are needed, and neither is sufficient:
|
|
210
|
+
*
|
|
211
|
+
* - **Wait for the job to go.** After a bootout, poll `launchctl print` until
|
|
212
|
+
* it reports the label gone (a non-zero exit), up to {@link BOOTOUT_POLLS}
|
|
213
|
+
* times. That removes the race in the case we caused it.
|
|
214
|
+
* - **Retry the bootstrap.** A bootout we did NOT issue can still be in
|
|
215
|
+
* flight — `service start` after a separate process's `service stop`, or a
|
|
216
|
+
* login racing a logout — and there is nothing to poll for there, because
|
|
217
|
+
* from this process's point of view the job was already gone. So a
|
|
218
|
+
* bootstrap that fails with a race code is retried with a short backoff.
|
|
219
|
+
*
|
|
220
|
+
* Timing out the poll is not an error: the retry is the backstop, and a job
|
|
221
|
+
* that will not go is a real failure the bootstrap reports in its own words.
|
|
222
|
+
*/
|
|
223
|
+
const BOOTOUT_POLLS = 40;
|
|
224
|
+
const BOOTOUT_POLL_MS = 250; // 40 × 250ms = 10s
|
|
225
|
+
const BOOTSTRAP_ATTEMPTS = 6;
|
|
226
|
+
const BOOTSTRAP_BACKOFF_MS = 500;
|
|
227
|
+
/**
|
|
228
|
+
* launchctl exits that mean "try again", as opposed to "no".
|
|
229
|
+
*
|
|
230
|
+
* `5` is `EIO`, what the release failure printed. `37` is launchd's "operation
|
|
231
|
+
* already in progress", which is the same race seen from the other side: the
|
|
232
|
+
* bootout is still being processed. Anything else — a malformed plist, a label
|
|
233
|
+
* genuinely already loaded, a permission problem — is reported, not retried,
|
|
234
|
+
* because retrying it only delays the message by three seconds.
|
|
235
|
+
*/
|
|
236
|
+
const BOOTSTRAP_RACE_CODES = new Set([5, 37]);
|
|
237
|
+
/**
|
|
238
|
+
* Block this thread for `ms`.
|
|
239
|
+
*
|
|
240
|
+
* The backend's {@link Runner} is synchronous (`execFileSync`) and so is every
|
|
241
|
+
* `ServiceBackend` method, so the wait has to be too — making `install` async
|
|
242
|
+
* would ripple through the whole service CLI for a sleep. `Atomics.wait` on a
|
|
243
|
+
* throwaway buffer is the built-in way to do it; `setTimeout` cannot help here
|
|
244
|
+
* and spinning on `Date.now()` would burn a core.
|
|
245
|
+
*
|
|
246
|
+
* Injectable because the unit tests drive the retry path with a fake runner
|
|
247
|
+
* that fails every attempt: with the real sleep, pinning the retry count would
|
|
248
|
+
* cost three seconds per case.
|
|
249
|
+
*/
|
|
250
|
+
export function sleepSync(ms) {
|
|
251
|
+
if (ms <= 0)
|
|
252
|
+
return;
|
|
253
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
254
|
+
}
|
|
255
|
+
export function createLaunchdBackend(run, homeDir = os.homedir(), sleep = sleepSync) {
|
|
192
256
|
const file = plistPath(homeDir);
|
|
257
|
+
/**
|
|
258
|
+
* Boot the job out and wait until launchd agrees it has gone — see the race
|
|
259
|
+
* note above. The bootout's own result is deliberately not inspected: "there
|
|
260
|
+
* was no previous service" is the expected case on a first install, and a
|
|
261
|
+
* bootout that genuinely failed shows up as a job that is still there, which
|
|
262
|
+
* is what the poll below is looking at anyway.
|
|
263
|
+
*/
|
|
264
|
+
const bootoutAndWait = () => {
|
|
265
|
+
run("launchctl", ["bootout", target()]);
|
|
266
|
+
// Counted, not timed: an injected no-op sleep has to terminate the loop
|
|
267
|
+
// too, and a wall-clock deadline with a no-op sleep is a ten-second spin.
|
|
268
|
+
for (let poll = 1; poll <= BOOTOUT_POLLS; poll++) {
|
|
269
|
+
// A non-zero `print` is launchd saying it has never heard of the label,
|
|
270
|
+
// which is precisely the state `bootstrap` needs.
|
|
271
|
+
if (run("launchctl", ["print", target()]).status !== 0)
|
|
272
|
+
return;
|
|
273
|
+
if (poll < BOOTOUT_POLLS)
|
|
274
|
+
sleep(BOOTOUT_POLL_MS);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
/** Bootstrap the plist, retrying the race codes. Throws with launchctl's own words. */
|
|
278
|
+
const bootstrapOrThrow = () => {
|
|
279
|
+
for (let attempt = 1;; attempt++) {
|
|
280
|
+
// NOT `load`/`unload`: those are the legacy API, and on a modern macOS
|
|
281
|
+
// they are shims over this one that report success in cases where it
|
|
282
|
+
// reports the actual error.
|
|
283
|
+
const boot = run("launchctl", ["bootstrap", domain(), file]);
|
|
284
|
+
if (boot.status === 0)
|
|
285
|
+
return;
|
|
286
|
+
if (attempt >= BOOTSTRAP_ATTEMPTS || !BOOTSTRAP_RACE_CODES.has(boot.status ?? -1)) {
|
|
287
|
+
throw new Error(launchctlFailure("bootstrap", boot));
|
|
288
|
+
}
|
|
289
|
+
sleep(BOOTSTRAP_BACKOFF_MS);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
193
292
|
return {
|
|
194
293
|
platform: "darwin",
|
|
195
294
|
unitPath: file,
|
|
@@ -202,16 +301,12 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
|
|
|
202
301
|
fs.mkdirSync(path.dirname(spec.logPath), { recursive: true });
|
|
203
302
|
fs.writeFileSync(file, renderPlist(spec), "utf8");
|
|
204
303
|
// `bootstrap` fails outright if the label is already loaded, so a
|
|
205
|
-
// reinstall has to unload first
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
// reports the actual error.
|
|
212
|
-
const boot = run("launchctl", ["bootstrap", domain(), file]);
|
|
213
|
-
if (boot.status !== 0)
|
|
214
|
-
throw new Error(launchctlFailure("bootstrap", boot));
|
|
304
|
+
// reinstall has to unload first — and has to WAIT for that, which is what
|
|
305
|
+
// `bootoutAndWait` is for (see the race note). A first install has
|
|
306
|
+
// nothing to unload, and says so on stderr: ignored rather than reported,
|
|
307
|
+
// because "there was no previous service" is the expected case.
|
|
308
|
+
bootoutAndWait();
|
|
309
|
+
bootstrapOrThrow();
|
|
215
310
|
// `RunAtLoad` should already have started it; `kickstart -k` makes that
|
|
216
311
|
// true rather than probable, and is the same command a later restart uses.
|
|
217
312
|
run("launchctl", ["kickstart", "-k", target()]);
|
|
@@ -230,11 +325,13 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
|
|
|
230
325
|
* Paddock is not merely idle, it is absent from the domain — and `start` on
|
|
231
326
|
* a label launchd has never heard of fails. Bootstrapping is also what a
|
|
232
327
|
* login does, so `service start` and the next login take the same path.
|
|
328
|
+
*
|
|
329
|
+
* No bootout to wait on here, but the retry in `bootstrapOrThrow` still
|
|
330
|
+
* earns its keep: the `stop` that booted it out may have been a different
|
|
331
|
+
* `paddock` process seconds ago, and launchd may not have finished with it.
|
|
233
332
|
*/
|
|
234
333
|
start() {
|
|
235
|
-
|
|
236
|
-
if (boot.status !== 0)
|
|
237
|
-
throw new Error(launchctlFailure("bootstrap", boot));
|
|
334
|
+
bootstrapOrThrow();
|
|
238
335
|
run("launchctl", ["kickstart", target()]);
|
|
239
336
|
},
|
|
240
337
|
/**
|
|
@@ -263,12 +360,13 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
|
|
|
263
360
|
* service that is stopped. This sequence is identical to `install`'s minus
|
|
264
361
|
* writing the plist, which also gives `restart` the property the issue asks
|
|
265
362
|
* for — the unit is re-read on the way up, so an edited plist takes effect.
|
|
363
|
+
*
|
|
364
|
+
* This is the sequence that failed Paddock 0.80.0's release run: bootout
|
|
365
|
+
* and bootstrap back to back, with nothing between them. See the race note.
|
|
266
366
|
*/
|
|
267
367
|
restart() {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (boot.status !== 0)
|
|
271
|
-
throw new Error(launchctlFailure("bootstrap", boot));
|
|
368
|
+
bootoutAndWait();
|
|
369
|
+
bootstrapOrThrow();
|
|
272
370
|
run("launchctl", ["kickstart", "-k", target()]);
|
|
273
371
|
},
|
|
274
372
|
status() {
|
|
@@ -81,3 +81,33 @@ export declare const READY_TIMEOUT_LABEL = "20 seconds";
|
|
|
81
81
|
* would park here well past `timeoutMs`.
|
|
82
82
|
*/
|
|
83
83
|
export declare function waitForReady(opts: ReadyOptions): Promise<Readiness>;
|
|
84
|
+
/** What {@link waitForVersion} concluded (CLI contract SPEC §5 step 12). */
|
|
85
|
+
export type VersionVerdict = {
|
|
86
|
+
state: "ok";
|
|
87
|
+
version: string;
|
|
88
|
+
}
|
|
89
|
+
/** Something answers, and it is not the version we were waiting for. */
|
|
90
|
+
| {
|
|
91
|
+
state: "wrong-version";
|
|
92
|
+
version: string;
|
|
93
|
+
} | {
|
|
94
|
+
state: "no-answer";
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Poll until `/api/health` reports exactly `version` (SPEC §5 step 12).
|
|
98
|
+
*
|
|
99
|
+
* The version is the point, and it is what makes `update`'s health check mean
|
|
100
|
+
* something. "The URL answers" is satisfied by the OLD server that has not
|
|
101
|
+
* finished shutting down, by a sibling instance on the same port, and by a
|
|
102
|
+
* supervisor that restarted the old unit because the new one crashed — all
|
|
103
|
+
* three of which are exactly the failures step 13's rollback exists for. An
|
|
104
|
+
* update that polled for a 200 would call every one of them a success.
|
|
105
|
+
*
|
|
106
|
+
* The distinction between "no answer" and "the wrong version answers" is kept
|
|
107
|
+
* because the two read completely differently in a failure report: one is a
|
|
108
|
+
* server that did not come up, the other is a server that came up as something
|
|
109
|
+
* else.
|
|
110
|
+
*/
|
|
111
|
+
export declare function waitForVersion(opts: ReadyOptions & {
|
|
112
|
+
version: string;
|
|
113
|
+
}): Promise<VersionVerdict>;
|
|
@@ -99,3 +99,38 @@ export async function waitForReady(opts) {
|
|
|
99
99
|
await sleep(intervalMs);
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Poll until `/api/health` reports exactly `version` (SPEC §5 step 12).
|
|
104
|
+
*
|
|
105
|
+
* The version is the point, and it is what makes `update`'s health check mean
|
|
106
|
+
* something. "The URL answers" is satisfied by the OLD server that has not
|
|
107
|
+
* finished shutting down, by a sibling instance on the same port, and by a
|
|
108
|
+
* supervisor that restarted the old unit because the new one crashed — all
|
|
109
|
+
* three of which are exactly the failures step 13's rollback exists for. An
|
|
110
|
+
* update that polled for a 200 would call every one of them a success.
|
|
111
|
+
*
|
|
112
|
+
* The distinction between "no answer" and "the wrong version answers" is kept
|
|
113
|
+
* because the two read completely differently in a failure report: one is a
|
|
114
|
+
* server that did not come up, the other is a server that came up as something
|
|
115
|
+
* else.
|
|
116
|
+
*/
|
|
117
|
+
export async function waitForVersion(opts) {
|
|
118
|
+
const { url, version, timeoutMs = READY_TIMEOUT_MS, intervalMs = DEFAULT_INTERVAL_MS, fetchImpl = fetch, now = () => Date.now(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)), } = opts;
|
|
119
|
+
const deadline = now() + timeoutMs;
|
|
120
|
+
let last;
|
|
121
|
+
for (;;) {
|
|
122
|
+
const health = await fetchHealth(url, {
|
|
123
|
+
fetchImpl,
|
|
124
|
+
timeoutMs: Math.max(250, Math.min(2_000, deadline - now())),
|
|
125
|
+
});
|
|
126
|
+
if (health?.version !== undefined) {
|
|
127
|
+
if (health.version === version)
|
|
128
|
+
return { state: "ok", version };
|
|
129
|
+
last = health.version;
|
|
130
|
+
}
|
|
131
|
+
if (deadline - now() <= 0) {
|
|
132
|
+
return last !== undefined ? { state: "wrong-version", version: last } : { state: "no-answer" };
|
|
133
|
+
}
|
|
134
|
+
await sleep(intervalMs);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -224,6 +224,9 @@ export function createSystemdBackend(run, homeDir, xdgConfigHome) {
|
|
|
224
224
|
unitPath: file,
|
|
225
225
|
label: SYSTEMD_UNIT,
|
|
226
226
|
install(spec) {
|
|
227
|
+
// Asked BEFORE the unit is overwritten, because the answer decides whether a restart is
|
|
228
|
+
// needed below and the file is about to stop describing what is running.
|
|
229
|
+
const wasActive = fs.existsSync(file) && run("systemctl", ["--user", "is-active", SYSTEMD_UNIT]).stdout.trim() === "active";
|
|
227
230
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
228
231
|
fs.mkdirSync(spec.workingDirectory, { recursive: true });
|
|
229
232
|
// systemd creates the log FILE for `append:` but not its directory, and a
|
|
@@ -239,6 +242,30 @@ export function createSystemdBackend(run, homeDir, xdgConfigHome) {
|
|
|
239
242
|
const enabled = run("systemctl", args);
|
|
240
243
|
if (enabled.status !== 0)
|
|
241
244
|
throw new Error(systemctlFailure(args, enabled));
|
|
245
|
+
// And then RESTART, but only if it was ALREADY active, because `enable --now` does nothing
|
|
246
|
+
// to a unit that is already running — systemd starts it only if it is stopped. So a
|
|
247
|
+
// reinstall over a running service wrote a new unit, left the old process serving, and
|
|
248
|
+
// reported success.
|
|
249
|
+
//
|
|
250
|
+
// That is not a theoretical gap: it is the one `paddock update`'s ROLLBACK walks into. The
|
|
251
|
+
// forward path stops the service first (SPEC §5 step 9), so `enable --now` starts the new
|
|
252
|
+
// version cleanly; the rollback runs `service install --refresh` while the FAILED version
|
|
253
|
+
// is still up, and without this the "restored" service was the broken one — which turns a
|
|
254
|
+
// clean exit 6 into an exit 7 and a machine nobody has fixed.
|
|
255
|
+
//
|
|
256
|
+
// Conditional rather than unconditional, even though `restart` would converge both states
|
|
257
|
+
// in one call: on a stopped unit that would be TWO starts milliseconds apart, and systemd's
|
|
258
|
+
// default start limit is five in ten seconds. Halving the headroom of a unit whose whole
|
|
259
|
+
// job is to come back is not worth saving a question.
|
|
260
|
+
//
|
|
261
|
+
// launchd needs no equivalent — its install is a bootout followed by a bootstrap, which
|
|
262
|
+
// already replaces a running job.
|
|
263
|
+
if (wasActive) {
|
|
264
|
+
const restartArgs = ["--user", "restart", SYSTEMD_UNIT];
|
|
265
|
+
const restarted = run("systemctl", restartArgs);
|
|
266
|
+
if (restarted.status !== 0)
|
|
267
|
+
throw new Error(systemctlFailure(restartArgs, restarted));
|
|
268
|
+
}
|
|
242
269
|
},
|
|
243
270
|
uninstall() {
|
|
244
271
|
const existed = fs.existsSync(file);
|