@edspencer/paddock 0.79.1 → 0.81.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 +19 -15
- 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 +63 -7
- package/packages/server/dist/cli/args.js +227 -20
- 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/exit-codes.d.ts +49 -0
- package/packages/server/dist/cli/exit-codes.js +48 -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 +98 -37
- package/packages/server/dist/cli/service/backend.d.ts +48 -4
- package/packages/server/dist/cli/service/index.d.ts +68 -2
- package/packages/server/dist/cli/service/index.js +321 -60
- package/packages/server/dist/cli/service/launchd.d.ts +23 -1
- package/packages/server/dist/cli/service/launchd.js +145 -25
- package/packages/server/dist/cli/service/logs.d.ts +39 -0
- package/packages/server/dist/cli/service/logs.js +115 -0
- package/packages/server/dist/cli/service/path-env.d.ts +27 -0
- package/packages/server/dist/cli/service/path-env.js +120 -0
- package/packages/server/dist/cli/service/ready.d.ts +22 -0
- package/packages/server/dist/cli/service/ready.js +30 -0
- package/packages/server/dist/cli/service/spec.d.ts +45 -6
- package/packages/server/dist/cli/service/spec.js +34 -7
- package/packages/server/dist/cli/service/systemd.d.ts +47 -4
- package/packages/server/dist/cli/service/systemd.js +127 -22
- package/packages/server/dist/herdctl.d.ts +19 -0
- package/packages/server/dist/herdctl.js +26 -0
- package/packages/server/dist/management-mcp-server.d.ts +21 -3
- package/packages/server/dist/management-mcp-server.js +4 -13
- package/packages/server/dist/routes/meta.js +56 -3
- package/packages/server/dist/runtime-file.d.ts +26 -0
- package/packages/server/dist/runtime-file.js +91 -0
- package/packages/server/dist/service-log.d.ts +29 -0
- package/packages/server/dist/service-log.js +104 -0
- package/packages/server/dist/start.d.ts +33 -0
- package/packages/server/dist/start.js +126 -8
- package/packages/server/dist/version.d.ts +3 -0
- package/packages/server/dist/version.js +32 -0
- 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-BcxJMA1w.js → DeleteProjectDialog-D8oIJ8HC.js} +1 -1
- package/packages/web/dist/assets/DiscoverPage-CQlzxsn4.js +2 -0
- package/packages/web/dist/assets/{DiscoverView-DOmTmiVv.js → DiscoverView-o0KCr3aB.js} +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-DWLQqHW0.js → InstanceConfigPage-RSJz7oL3.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-Y25JWlMR.js → MarkdownRenderer-39mafH8b.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-CCI1VWM6.js → ProjectRedirect-Bq7GVguY.js} +1 -1
- package/packages/web/dist/assets/{ProjectView-DzfVhjya.js → ProjectView-BoBIru1g.js} +3 -3
- package/packages/web/dist/assets/{ProjectsGrid-yIyyb2HA.js → ProjectsGrid-mJ4gqbr6.js} +1 -1
- package/packages/web/dist/assets/{RootHome-x0Om9-Fd.js → RootHome-C0kwFyYp.js} +1 -1
- package/packages/web/dist/assets/{Surfaces-wbcMQIsK.js → Surfaces-MkhXlB8z.js} +1 -1
- package/packages/web/dist/assets/{arc-tWN7FYIq.js → arc-DExlwAwO.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Ccj6Vlyy.js → architectureDiagram-3BPJPVTR-CZly3W79.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-B7iGXwqY.js → blockDiagram-GPEHLZMM-AgES73i3.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-SkLKFSso.js → c4Diagram-AAUBKEIU-N9mD8Pfk.js} +1 -1
- package/packages/web/dist/assets/channel-ExsAG7qT.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-BrS8C-YK.js → chunk-2J33WTMH-Dsx_kfUl.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-BUR6nG8w.js → chunk-4BX2VUAB-BQjqcjPj.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-BRBNJb-u.js → chunk-55IACEB6-CCxpYQpY.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-DKeNPTXz.js → chunk-727SXJPM-DpFRdnUa.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-BOS1K-DX.js → chunk-AQP2D5EJ-D2BAlzoO.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-D3PZL8fo.js → chunk-FMBD7UC4-BFaMRzUF.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-DA9Y77Zj.js → chunk-ND2GUHAM-B9NGJ9xZ.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-wPph-weq.js → chunk-QZHKN3VN-CF1IHyMS.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-si7g9okq.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-si7g9okq.js +2 -0
- package/packages/web/dist/assets/{core-I1PQdGMJ.js → core-XjElsvQ7.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BA7N2vVK.js → cose-bilkent-S5V4N54A-CF972Flx.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-DKpON5p2.js → dagre-BM42HDAG-DsneI8YE.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-Db0I-eEL.js → diagram-2AECGRRQ-BHyrVzIk.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-BEuqOTfn.js → diagram-5GNKFQAL-Dj4q8xEi.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-CnyX_0Xk.js → diagram-KO2AKTUF-BHe3Puv_.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-CYvknzDd.js → diagram-LMA3HP47-wzt6G2FE.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-DlgKxbYz.js → diagram-OG6HWLK6-CZMbMRJq.js} +1 -1
- package/packages/web/dist/assets/{documentTitle-Cvob-HVs.js → documentTitle-BwTIA8rI.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-BkO5JmiM.js → erDiagram-TEJ5UH35-Dtu14BL1.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DG0o_zc1.js → flowDiagram-I6XJVG4X-T34ql9j0.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-Bs71No2S.js → ganttDiagram-6RSMTGT7-DQLOeCXK.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-CxODhRKd.js → gitGraphDiagram-PVQCEYII-Bk11zea6.js} +1 -1
- package/packages/web/dist/assets/{index-C4tsPSvG.js → index-CvaWErFv.js} +2 -2
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-C412TQaw.js → infoDiagram-5YYISTIA-CsE83lu0.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DUlcZXQQ.js → ishikawaDiagram-YF4QCWOH-DNVgA93n.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-iL0klVhn.js → journeyDiagram-JHISSGLW-CLpSNjM3.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-CO02Zl_Q.js → kanban-definition-UN3LZRKU-BRzuXcKr.js} +1 -1
- package/packages/web/dist/assets/{linear-DtmLY8AG.js → linear-BxOawONt.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-Dvzxug80.js → mermaid.core-Dvs1QCcz.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-q950AAWL.js → mindmap-definition-RKZ34NQL-Dmt7pYwF.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BVrv8A-H.js → pieDiagram-4H26LBE5-BHiZaJD-.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bp5D2MFq.js → quadrantDiagram-W4KKPZXB-U0A1noRE.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-yZfQU4Ix.js → requirementDiagram-4Y6WPE33-CZLsr6yi.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-5R8_OZir.js → sankeyDiagram-5OEKKPKP-BTlLGILU.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C3A9rinG.js → sequenceDiagram-3UESZ5HK-DpSS_Gb1.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-Cshrt_XB.js → stateDiagram-AJRCARHV-D9srE-tV.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-AxWqsDh-.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-kNkiddCX.js → timeline-definition-PNZ67QCA-BYQ5CKab.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-BiNJm17g.js → vennDiagram-CIIHVFJN-B4W2CtSV.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-CjExbCuX.js → wardley-L42UT6IY--XUV7GvD.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CMn3z-G2.js → wardleyDiagram-YWT4CUSO-Bl2VIEGY.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-B-UhnooW.js → xychartDiagram-2RQKCTM6-Bj2H9Rcf.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-CSPRrFVk.js +0 -2
- package/packages/web/dist/assets/channel-B2NSmlBB.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-B3tLFeZC.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-B3tLFeZC.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-DgiazF78.js +0 -2
|
@@ -31,6 +31,12 @@ export declare function plistPath(homeDir?: string): string;
|
|
|
31
31
|
*
|
|
32
32
|
* No `PADDOCK_DATA_DIR`: see `buildSpec`. One `~/.paddock` instance, reachable
|
|
33
33
|
* from the service and from a terminal.
|
|
34
|
+
*
|
|
35
|
+
* `StandardOutPath` and `StandardErrorPath` name the SAME file (SPEC §7).
|
|
36
|
+
* launchd opens both in append mode, so interleaving is what a reader wants
|
|
37
|
+
* anyway — a warning and the stack trace that followed it used to land in two
|
|
38
|
+
* separate files, in an order nothing recorded. One file is also what makes
|
|
39
|
+
* `paddock service logs` one implementation across platforms.
|
|
34
40
|
*/
|
|
35
41
|
export declare function renderPlist(spec: ServiceSpec): string;
|
|
36
42
|
/**
|
|
@@ -55,6 +61,8 @@ export declare function parsePlistArgv(xml: string): string[];
|
|
|
55
61
|
* unrecognised would silence exactly the case this exists to catch.
|
|
56
62
|
*/
|
|
57
63
|
export declare function plistKeepsAlive(xml: string): boolean;
|
|
64
|
+
/** The log file an installed plist appends to, read back from `StandardOutPath`. */
|
|
65
|
+
export declare function parsePlistLogPath(xml: string): string | undefined;
|
|
58
66
|
/**
|
|
59
67
|
* Read liveness out of `launchctl print gui/<uid>/<label>`.
|
|
60
68
|
*
|
|
@@ -67,4 +75,18 @@ export declare function parseLaunchctlPrint(out: string): {
|
|
|
67
75
|
running: boolean;
|
|
68
76
|
pid?: string;
|
|
69
77
|
};
|
|
70
|
-
|
|
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;
|
|
@@ -77,6 +77,12 @@ function xmlEscape(s) {
|
|
|
77
77
|
*
|
|
78
78
|
* No `PADDOCK_DATA_DIR`: see `buildSpec`. One `~/.paddock` instance, reachable
|
|
79
79
|
* from the service and from a terminal.
|
|
80
|
+
*
|
|
81
|
+
* `StandardOutPath` and `StandardErrorPath` name the SAME file (SPEC §7).
|
|
82
|
+
* launchd opens both in append mode, so interleaving is what a reader wants
|
|
83
|
+
* anyway — a warning and the stack trace that followed it used to land in two
|
|
84
|
+
* separate files, in an order nothing recorded. One file is also what makes
|
|
85
|
+
* `paddock service logs` one implementation across platforms.
|
|
80
86
|
*/
|
|
81
87
|
export function renderPlist(spec) {
|
|
82
88
|
const argv = [spec.nodePath, spec.scriptPath, ...spec.args];
|
|
@@ -100,9 +106,9 @@ ${strings}
|
|
|
100
106
|
<key>WorkingDirectory</key>
|
|
101
107
|
<string>${xmlEscape(spec.workingDirectory)}</string>
|
|
102
108
|
<key>StandardOutPath</key>
|
|
103
|
-
<string>${xmlEscape(spec.
|
|
109
|
+
<string>${xmlEscape(spec.logPath)}</string>
|
|
104
110
|
<key>StandardErrorPath</key>
|
|
105
|
-
<string>${xmlEscape(spec.
|
|
111
|
+
<string>${xmlEscape(spec.logPath)}</string>
|
|
106
112
|
<key>EnvironmentVariables</key>
|
|
107
113
|
<dict>
|
|
108
114
|
<key>PATH</key>
|
|
@@ -145,6 +151,17 @@ export function parsePlistArgv(xml) {
|
|
|
145
151
|
export function plistKeepsAlive(xml) {
|
|
146
152
|
return /<key>KeepAlive<\/key>\s*<true\s*\/>/.test(xml);
|
|
147
153
|
}
|
|
154
|
+
/** The log file an installed plist appends to, read back from `StandardOutPath`. */
|
|
155
|
+
export function parsePlistLogPath(xml) {
|
|
156
|
+
const m = /<key>StandardOutPath<\/key>\s*<string>([\s\S]*?)<\/string>/.exec(xml);
|
|
157
|
+
if (m === null)
|
|
158
|
+
return undefined;
|
|
159
|
+
return m[1]
|
|
160
|
+
.replace(/"/g, '"')
|
|
161
|
+
.replace(/>/g, ">")
|
|
162
|
+
.replace(/</g, "<")
|
|
163
|
+
.replace(/&/g, "&");
|
|
164
|
+
}
|
|
148
165
|
/**
|
|
149
166
|
* Read liveness out of `launchctl print gui/<uid>/<label>`.
|
|
150
167
|
*
|
|
@@ -171,8 +188,107 @@ function target() {
|
|
|
171
188
|
function domain() {
|
|
172
189
|
return `gui/${process.getuid?.() ?? 0}`;
|
|
173
190
|
}
|
|
174
|
-
|
|
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) {
|
|
175
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
|
+
};
|
|
176
292
|
return {
|
|
177
293
|
platform: "darwin",
|
|
178
294
|
unitPath: file,
|
|
@@ -180,18 +296,17 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
|
|
|
180
296
|
install(spec) {
|
|
181
297
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
182
298
|
fs.mkdirSync(spec.workingDirectory, { recursive: true });
|
|
299
|
+
// launchd creates neither the log file's directory nor, reliably, a log
|
|
300
|
+
// file whose parent is missing — the job just fails to spawn.
|
|
301
|
+
fs.mkdirSync(path.dirname(spec.logPath), { recursive: true });
|
|
183
302
|
fs.writeFileSync(file, renderPlist(spec), "utf8");
|
|
184
303
|
// `bootstrap` fails outright if the label is already loaded, so a
|
|
185
|
-
// reinstall has to unload first
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
// reports the actual error.
|
|
192
|
-
const boot = run("launchctl", ["bootstrap", domain(), file]);
|
|
193
|
-
if (boot.status !== 0)
|
|
194
|
-
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();
|
|
195
310
|
// `RunAtLoad` should already have started it; `kickstart -k` makes that
|
|
196
311
|
// true rather than probable, and is the same command a later restart uses.
|
|
197
312
|
run("launchctl", ["kickstart", "-k", target()]);
|
|
@@ -210,11 +325,13 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
|
|
|
210
325
|
* Paddock is not merely idle, it is absent from the domain — and `start` on
|
|
211
326
|
* a label launchd has never heard of fails. Bootstrapping is also what a
|
|
212
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.
|
|
213
332
|
*/
|
|
214
333
|
start() {
|
|
215
|
-
|
|
216
|
-
if (boot.status !== 0)
|
|
217
|
-
throw new Error(launchctlFailure("bootstrap", boot));
|
|
334
|
+
bootstrapOrThrow();
|
|
218
335
|
run("launchctl", ["kickstart", target()]);
|
|
219
336
|
},
|
|
220
337
|
/**
|
|
@@ -243,12 +360,13 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
|
|
|
243
360
|
* service that is stopped. This sequence is identical to `install`'s minus
|
|
244
361
|
* writing the plist, which also gives `restart` the property the issue asks
|
|
245
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.
|
|
246
366
|
*/
|
|
247
367
|
restart() {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
if (boot.status !== 0)
|
|
251
|
-
throw new Error(launchctlFailure("bootstrap", boot));
|
|
368
|
+
bootoutAndWait();
|
|
369
|
+
bootstrapOrThrow();
|
|
252
370
|
run("launchctl", ["kickstart", "-k", target()]);
|
|
253
371
|
},
|
|
254
372
|
status() {
|
|
@@ -260,6 +378,7 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
|
|
|
260
378
|
const parsed = printed.status === 0
|
|
261
379
|
? parseLaunchctlPrint(printed.stdout)
|
|
262
380
|
: { running: false, pid: undefined };
|
|
381
|
+
const logPath = parsePlistLogPath(xml);
|
|
263
382
|
return {
|
|
264
383
|
registered: true,
|
|
265
384
|
running: parsed.running,
|
|
@@ -267,14 +386,15 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
|
|
|
267
386
|
// Strip the interpreter and script: what a reader wants from `status`
|
|
268
387
|
// is the Paddock invocation, not the absolute path to node twice.
|
|
269
388
|
argv: argv.slice(2),
|
|
389
|
+
...(argv[0] !== undefined ? { nodePath: argv[0] } : {}),
|
|
390
|
+
...(argv[1] !== undefined ? { cliPath: argv[1] } : {}),
|
|
391
|
+
...(logPath !== undefined ? { logPath } : {}),
|
|
270
392
|
keepsAlive: plistKeepsAlive(xml),
|
|
271
393
|
};
|
|
272
394
|
},
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
lingerNote() {
|
|
277
|
-
return undefined;
|
|
395
|
+
/** launchd has no linger: a LaunchAgent's lifetime IS the login session. */
|
|
396
|
+
linger() {
|
|
397
|
+
return { applies: false };
|
|
278
398
|
},
|
|
279
399
|
};
|
|
280
400
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** How many lines `logs` prints when nobody says otherwise (SPEC §1). */
|
|
2
|
+
export declare const DEFAULT_LOG_LINES = 50;
|
|
3
|
+
/**
|
|
4
|
+
* The last `lines` lines of `file`.
|
|
5
|
+
*
|
|
6
|
+
* Only the last {@link TAIL_WINDOW} bytes are read. A service log is append-only
|
|
7
|
+
* and can reach tens of megabytes between rotations, and reading all of it to
|
|
8
|
+
* print fifty lines would make `logs` slower than the thing it is reporting on.
|
|
9
|
+
* A window that happens to start mid-line drops that partial first line rather
|
|
10
|
+
* than printing half of one.
|
|
11
|
+
*/
|
|
12
|
+
export declare function tailFile(file: string, lines: number): string;
|
|
13
|
+
export interface FollowOptions {
|
|
14
|
+
/** Where to start reading from — normally the file's size after the tail. */
|
|
15
|
+
from: number;
|
|
16
|
+
/** Injected for tests. */
|
|
17
|
+
intervalMs?: number;
|
|
18
|
+
/** Injected for tests: return false to stop the loop. */
|
|
19
|
+
keepGoing?: () => boolean;
|
|
20
|
+
write?: (s: string) => void;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Print what gets appended to `file`, from `from` onwards, until the process is
|
|
24
|
+
* interrupted.
|
|
25
|
+
*
|
|
26
|
+
* Polling rather than `fs.watch`: the writer here is the supervisor's own file
|
|
27
|
+
* descriptor, and `fs.watch` on an appended-to file is unreliable across
|
|
28
|
+
* platforms (no events at all on some network filesystems, and a stream of
|
|
29
|
+
* them on others). A 400 ms poll of the size is both portable and cheap.
|
|
30
|
+
*
|
|
31
|
+
* Rotation is handled by the one case that can actually happen: the server
|
|
32
|
+
* rotates by COPY-AND-TRUNCATE (see `service-log.ts`), so the inode is stable
|
|
33
|
+
* and the size goes DOWN. Seeing that, this resets to the new start rather than
|
|
34
|
+
* reading past the end forever, which is what a naive follower does — it goes
|
|
35
|
+
* quiet after the first rotation and never recovers.
|
|
36
|
+
*/
|
|
37
|
+
export declare function followFile(file: string, opts: FollowOptions): Promise<void>;
|
|
38
|
+
/** The file's size, or 0 if it is not there — where a follow should start. */
|
|
39
|
+
export declare function sizeOf(file: string): number;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `paddock service logs [-f] [-n <lines>]` (SPEC §1, §7).
|
|
3
|
+
*
|
|
4
|
+
* One implementation for every platform, which is the whole reason the units
|
|
5
|
+
* write to a file rather than to the journal: `journalctl --user -u paddock` on
|
|
6
|
+
* Linux and a pair of files on macOS meant "where are the logs" had two
|
|
7
|
+
* answers, neither of which Windows could ever have.
|
|
8
|
+
*
|
|
9
|
+
* Reading the tail of a file is deliberately done by hand rather than by
|
|
10
|
+
* spawning `tail`: `tail -f` is not on every box this runs on (and is `Get-
|
|
11
|
+
* Content -Wait` on the platform the contract requires us not to rule out),
|
|
12
|
+
* and shelling out would make the output depend on which `tail` won a PATH
|
|
13
|
+
* lookup.
|
|
14
|
+
*/
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
/** How many lines `logs` prints when nobody says otherwise (SPEC §1). */
|
|
17
|
+
export const DEFAULT_LOG_LINES = 50;
|
|
18
|
+
/** How much of the end of the file to read for a tail, in bytes. */
|
|
19
|
+
const TAIL_WINDOW = 256 * 1024;
|
|
20
|
+
/** The gap between size checks when following. */
|
|
21
|
+
const FOLLOW_INTERVAL_MS = 400;
|
|
22
|
+
/**
|
|
23
|
+
* The last `lines` lines of `file`.
|
|
24
|
+
*
|
|
25
|
+
* Only the last {@link TAIL_WINDOW} bytes are read. A service log is append-only
|
|
26
|
+
* and can reach tens of megabytes between rotations, and reading all of it to
|
|
27
|
+
* print fifty lines would make `logs` slower than the thing it is reporting on.
|
|
28
|
+
* A window that happens to start mid-line drops that partial first line rather
|
|
29
|
+
* than printing half of one.
|
|
30
|
+
*/
|
|
31
|
+
export function tailFile(file, lines) {
|
|
32
|
+
const fd = fs.openSync(file, "r");
|
|
33
|
+
try {
|
|
34
|
+
const size = fs.fstatSync(fd).size;
|
|
35
|
+
const start = Math.max(0, size - TAIL_WINDOW);
|
|
36
|
+
const length = size - start;
|
|
37
|
+
if (length === 0)
|
|
38
|
+
return "";
|
|
39
|
+
const buf = Buffer.alloc(length);
|
|
40
|
+
fs.readSync(fd, buf, 0, length, start);
|
|
41
|
+
let text = buf.toString("utf8");
|
|
42
|
+
// A window that starts mid-line: drop the fragment.
|
|
43
|
+
if (start > 0) {
|
|
44
|
+
const nl = text.indexOf("\n");
|
|
45
|
+
text = nl === -1 ? "" : text.slice(nl + 1);
|
|
46
|
+
}
|
|
47
|
+
const all = text.split("\n");
|
|
48
|
+
// A trailing newline produces a final empty element that is not a line.
|
|
49
|
+
if (all[all.length - 1] === "")
|
|
50
|
+
all.pop();
|
|
51
|
+
return all.slice(-lines).join("\n");
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
fs.closeSync(fd);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Print what gets appended to `file`, from `from` onwards, until the process is
|
|
59
|
+
* interrupted.
|
|
60
|
+
*
|
|
61
|
+
* Polling rather than `fs.watch`: the writer here is the supervisor's own file
|
|
62
|
+
* descriptor, and `fs.watch` on an appended-to file is unreliable across
|
|
63
|
+
* platforms (no events at all on some network filesystems, and a stream of
|
|
64
|
+
* them on others). A 400 ms poll of the size is both portable and cheap.
|
|
65
|
+
*
|
|
66
|
+
* Rotation is handled by the one case that can actually happen: the server
|
|
67
|
+
* rotates by COPY-AND-TRUNCATE (see `service-log.ts`), so the inode is stable
|
|
68
|
+
* and the size goes DOWN. Seeing that, this resets to the new start rather than
|
|
69
|
+
* reading past the end forever, which is what a naive follower does — it goes
|
|
70
|
+
* quiet after the first rotation and never recovers.
|
|
71
|
+
*/
|
|
72
|
+
export async function followFile(file, opts) {
|
|
73
|
+
const interval = opts.intervalMs ?? FOLLOW_INTERVAL_MS;
|
|
74
|
+
const write = opts.write ?? ((s) => process.stdout.write(s));
|
|
75
|
+
const keepGoing = opts.keepGoing ?? (() => true);
|
|
76
|
+
let offset = opts.from;
|
|
77
|
+
while (keepGoing()) {
|
|
78
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
79
|
+
let size;
|
|
80
|
+
try {
|
|
81
|
+
size = fs.statSync(file).size;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// The file can vanish: an uninstall, or a rotation that renames. Wait for
|
|
85
|
+
// it to come back rather than giving up — `logs -f` is left running.
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (size < offset) {
|
|
89
|
+
// Truncated: the rotation above, or a hand-run `: > service.log`.
|
|
90
|
+
offset = 0;
|
|
91
|
+
}
|
|
92
|
+
if (size === offset)
|
|
93
|
+
continue;
|
|
94
|
+
const fd = fs.openSync(file, "r");
|
|
95
|
+
try {
|
|
96
|
+
const length = size - offset;
|
|
97
|
+
const buf = Buffer.alloc(length);
|
|
98
|
+
fs.readSync(fd, buf, 0, length, offset);
|
|
99
|
+
write(buf.toString("utf8"));
|
|
100
|
+
offset = size;
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
fs.closeSync(fd);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** The file's size, or 0 if it is not there — where a follow should start. */
|
|
108
|
+
export function sizeOf(file) {
|
|
109
|
+
try {
|
|
110
|
+
return fs.statSync(file).size;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** PATH entries that belong to one shell session or one npm invocation. */
|
|
2
|
+
export declare const PER_SHELL_PATTERNS: readonly RegExp[];
|
|
3
|
+
/** Where the usual tools live, after the inherited PATH. Only existing ones are kept. */
|
|
4
|
+
export declare const SYSTEM_DIRS: readonly string[];
|
|
5
|
+
export interface ServicePathOptions {
|
|
6
|
+
/** Injected for tests: "is this an existing directory?". */
|
|
7
|
+
exists?: (p: string) => boolean;
|
|
8
|
+
/** Injected for tests: the system directories to append. */
|
|
9
|
+
systemDirs?: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The job's PATH: Node's bin directory, then the usable part of `inherited`,
|
|
13
|
+
* then the system directories. De-duplicated, order preserved.
|
|
14
|
+
*/
|
|
15
|
+
export declare function servicePath(nodeBin: string, inherited: string | undefined, opts?: ServicePathOptions): string;
|
|
16
|
+
/** `which`: the first executable called `name` on `pathVar`, or undefined. */
|
|
17
|
+
export declare function which(name: string, pathVar: string, isExec?: (p: string) => boolean): string | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Of `paths`, those that are no longer on disk.
|
|
20
|
+
*
|
|
21
|
+
* This is the check behind `status`'s and (from wave G) `doctor`'s "the Node it
|
|
22
|
+
* runs has gone" warning. It happens under nvm, fnm and volta the moment a Node
|
|
23
|
+
* version is uninstalled: the unit still names an absolute interpreter path,
|
|
24
|
+
* the supervisor still dutifully tries to exec it, and the failure arrives as
|
|
25
|
+
* Paddock being absent with nothing in its own logs — because it never ran.
|
|
26
|
+
*/
|
|
27
|
+
export declare function missingPaths(paths: readonly (string | undefined)[], exists?: (p: string) => boolean): string[];
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PATH a generated unit gives the service (SPEC §7).
|
|
3
|
+
*
|
|
4
|
+
* From edspencer/cli-contract — Local Review's `server/service/spec.ts`
|
|
5
|
+
* (`PER_SHELL_PATTERNS`, `SYSTEM_DIRS`, `servicePath`, `which`) @ spec 0.1;
|
|
6
|
+
* local changes: Paddock's own doc comments, `node:path`'s platform-aware
|
|
7
|
+
* `delimiter`/`join` instead of hard-coded `:` and `path.posix`, and a
|
|
8
|
+
* `missingFromPath` helper the CLI uses for its unit warnings. Wave C4 moves
|
|
9
|
+
* this to `reference/service/` and re-syncs.
|
|
10
|
+
*
|
|
11
|
+
* ## Why a unit cannot just inherit the installing shell's PATH
|
|
12
|
+
*
|
|
13
|
+
* Paddock used to record `process.env.PATH` verbatim. That is the PATH of one
|
|
14
|
+
* terminal at one moment, and several of its entries are *per-session*:
|
|
15
|
+
*
|
|
16
|
+
* - **fnm** puts a symlink per terminal under `fnm_multishells/<pid>_<ts>/bin`.
|
|
17
|
+
* The directory is deleted when that shell exits — and fnm never cleans up
|
|
18
|
+
* the stale ones, so a unit installed from an fnm shell names a `node`
|
|
19
|
+
* directory that stops existing the moment the tab closes.
|
|
20
|
+
* - **npx** adds its own per-invocation cache; `npm run` adds `node-gyp-bin`
|
|
21
|
+
* and the package's `node_modules/.bin`. A long-lived background job has no
|
|
22
|
+
* business looking in any of them for `git`.
|
|
23
|
+
*
|
|
24
|
+
* So the job's PATH is rebuilt: Node's own bin directory first (so the `node`
|
|
25
|
+
* the unit runs can resolve its neighbours), then whatever of the installing
|
|
26
|
+
* shell's PATH is worth keeping, then the system directories. Entries that are
|
|
27
|
+
* relative, per-shell, or simply not there are dropped — a PATH naming
|
|
28
|
+
* directories that do not exist is how `git` goes missing at some later login,
|
|
29
|
+
* silently, with the symptom appearing in an agent's turn rather than at
|
|
30
|
+
* install time.
|
|
31
|
+
*/
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
/** PATH entries that belong to one shell session or one npm invocation. */
|
|
35
|
+
export const PER_SHELL_PATTERNS = [
|
|
36
|
+
/[/\\]fnm_multishells[/\\]/, // fnm: a symlink per terminal, never cleaned up
|
|
37
|
+
/[/\\]_npx[/\\]/, // npx's per-invocation package cache
|
|
38
|
+
/[/\\]node-gyp-bin$/, // added by `npm run` for the script it is running
|
|
39
|
+
/[/\\]node_modules[/\\]\.bin$/, // likewise (and by npx): a package's tools, not the user's
|
|
40
|
+
];
|
|
41
|
+
/** Where the usual tools live, after the inherited PATH. Only existing ones are kept. */
|
|
42
|
+
export const SYSTEM_DIRS = [
|
|
43
|
+
"/opt/homebrew/bin",
|
|
44
|
+
"/usr/local/bin",
|
|
45
|
+
"/usr/bin",
|
|
46
|
+
"/bin",
|
|
47
|
+
"/usr/sbin",
|
|
48
|
+
"/sbin",
|
|
49
|
+
];
|
|
50
|
+
function isDir(p) {
|
|
51
|
+
try {
|
|
52
|
+
return fs.statSync(p).isDirectory();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function isExecutableFile(p) {
|
|
59
|
+
try {
|
|
60
|
+
if (!fs.statSync(p).isFile())
|
|
61
|
+
return false;
|
|
62
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Kept on the job's PATH: non-empty, absolute, not per-shell, and an existing directory. */
|
|
70
|
+
function keep(dir, exists) {
|
|
71
|
+
if (dir === "" || !path.isAbsolute(dir))
|
|
72
|
+
return false;
|
|
73
|
+
if (PER_SHELL_PATTERNS.some((re) => re.test(dir) || re.test(`${dir}${path.sep}`)))
|
|
74
|
+
return false;
|
|
75
|
+
return exists(dir);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The job's PATH: Node's bin directory, then the usable part of `inherited`,
|
|
79
|
+
* then the system directories. De-duplicated, order preserved.
|
|
80
|
+
*/
|
|
81
|
+
export function servicePath(nodeBin, inherited, opts = {}) {
|
|
82
|
+
const exists = opts.exists ?? isDir;
|
|
83
|
+
const system = opts.systemDirs ?? SYSTEM_DIRS;
|
|
84
|
+
const out = [];
|
|
85
|
+
const add = (d) => {
|
|
86
|
+
// Trailing separators would make `/usr/bin/` and `/usr/bin` two entries.
|
|
87
|
+
const clean = d.length > 1 ? d.replace(/[/\\]+$/, "") : d;
|
|
88
|
+
if (!out.includes(clean) && keep(clean, exists))
|
|
89
|
+
out.push(clean);
|
|
90
|
+
};
|
|
91
|
+
add(nodeBin);
|
|
92
|
+
for (const d of (inherited ?? "").split(path.delimiter))
|
|
93
|
+
add(d);
|
|
94
|
+
for (const d of system)
|
|
95
|
+
add(d);
|
|
96
|
+
return out.join(path.delimiter);
|
|
97
|
+
}
|
|
98
|
+
/** `which`: the first executable called `name` on `pathVar`, or undefined. */
|
|
99
|
+
export function which(name, pathVar, isExec = isExecutableFile) {
|
|
100
|
+
for (const dir of pathVar.split(path.delimiter)) {
|
|
101
|
+
if (dir === "")
|
|
102
|
+
continue;
|
|
103
|
+
const p = path.join(dir, name);
|
|
104
|
+
if (isExec(p))
|
|
105
|
+
return p;
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Of `paths`, those that are no longer on disk.
|
|
111
|
+
*
|
|
112
|
+
* This is the check behind `status`'s and (from wave G) `doctor`'s "the Node it
|
|
113
|
+
* runs has gone" warning. It happens under nvm, fnm and volta the moment a Node
|
|
114
|
+
* version is uninstalled: the unit still names an absolute interpreter path,
|
|
115
|
+
* the supervisor still dutifully tries to exec it, and the failure arrives as
|
|
116
|
+
* Paddock being absent with nothing in its own logs — because it never ran.
|
|
117
|
+
*/
|
|
118
|
+
export function missingPaths(paths, exists = (p) => fs.existsSync(p)) {
|
|
119
|
+
return paths.filter((p) => p !== undefined && p !== "" && !exists(p));
|
|
120
|
+
}
|
|
@@ -23,6 +23,28 @@
|
|
|
23
23
|
*/
|
|
24
24
|
/** Where the poll got to when it stopped. */
|
|
25
25
|
export type Readiness = "ready" | "timeout";
|
|
26
|
+
/** What `/api/health` said, when it said anything (SPEC §8). */
|
|
27
|
+
export interface HealthAnswer {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
/** The version the RUNNING server reports. Absent from a pre-contract Paddock. */
|
|
30
|
+
version?: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Ask `<url>/api/health` once, and say what it answered.
|
|
34
|
+
*
|
|
35
|
+
* Distinct from {@link waitForReady}, which only needs a boolean and polls for
|
|
36
|
+
* it. `status` needs the body: the version in it is the only way to tell a
|
|
37
|
+
* server that is running the installed Paddock from one that has been up since
|
|
38
|
+
* before an upgrade — the single most confusing state a service can be in, and
|
|
39
|
+
* invisible from the outside until something reports both numbers.
|
|
40
|
+
*
|
|
41
|
+
* `undefined` means nothing answered. That is a real answer (exit 3), not an
|
|
42
|
+
* error, so nothing is thrown.
|
|
43
|
+
*/
|
|
44
|
+
export declare function fetchHealth(url: string, opts?: {
|
|
45
|
+
timeoutMs?: number;
|
|
46
|
+
fetchImpl?: typeof fetch;
|
|
47
|
+
}): Promise<HealthAnswer | undefined>;
|
|
26
48
|
export interface ReadyOptions {
|
|
27
49
|
/** Base URL of the server, e.g. `http://127.0.0.1:7233` — no trailing slash. */
|
|
28
50
|
url: string;
|
|
@@ -21,6 +21,36 @@
|
|
|
21
21
|
* same claim as "it failed". Callers print it as a caveat, not an error, and
|
|
22
22
|
* point at the logs.
|
|
23
23
|
*/
|
|
24
|
+
/**
|
|
25
|
+
* Ask `<url>/api/health` once, and say what it answered.
|
|
26
|
+
*
|
|
27
|
+
* Distinct from {@link waitForReady}, which only needs a boolean and polls for
|
|
28
|
+
* it. `status` needs the body: the version in it is the only way to tell a
|
|
29
|
+
* server that is running the installed Paddock from one that has been up since
|
|
30
|
+
* before an upgrade — the single most confusing state a service can be in, and
|
|
31
|
+
* invisible from the outside until something reports both numbers.
|
|
32
|
+
*
|
|
33
|
+
* `undefined` means nothing answered. That is a real answer (exit 3), not an
|
|
34
|
+
* error, so nothing is thrown.
|
|
35
|
+
*/
|
|
36
|
+
export async function fetchHealth(url, opts = {}) {
|
|
37
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetchImpl(`${url}/api/health`, {
|
|
40
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 2_000),
|
|
41
|
+
});
|
|
42
|
+
if (!res.ok)
|
|
43
|
+
return undefined;
|
|
44
|
+
const body = (await res.json());
|
|
45
|
+
return {
|
|
46
|
+
ok: body.ok === true,
|
|
47
|
+
...(typeof body.version === "string" ? { version: body.version } : {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
24
54
|
/**
|
|
25
55
|
* How long to wait before saying "could not confirm".
|
|
26
56
|
*
|