@indigoai-us/hq-cli 5.177.0 → 5.179.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/CHANGELOG.md +9 -2
- package/dist/lib/bot/daemon.js +20 -17
- package/dist/lib/daemon/install.d.ts +3 -1
- package/dist/lib/daemon/install.js +3 -3
- package/dist/lib/mesh/live/daemon/install.js +2 -2
- package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +16 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.js +66 -2
- package/dist/lib/service-manager/launchd.d.ts +44 -7
- package/dist/lib/service-manager/launchd.js +69 -14
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.179.0] — 2026-09-24
|
|
6
|
+
|
|
7
|
+
## [5.178.0] — 2026-09-24
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Work Mesh transcript-watch still tracks hook-covered sessions (interactive Claude/Codex) and enqueues `session_end` after 10 minutes of quiet. Hooks keep owning start/turn events; the quiet `session_end` is the channel work-report trigger those sessions were missing. Resume (transcript mtime or a later hook event) resets the timer. A SessionEnd hook within 60s of that emit is not treated as resume, so `/exit` does not double-fire.
|
|
12
|
+
- Reloading a macOS LaunchAgent (`hq mesh daemon install`, including `--if-stale` and the post-update restart, `hq daemon install`, `hq agent kit` services, and bot agents) now waits for `launchctl bootout` to finish before bootstrapping. Before, bootstrap could race the old job's teardown, fail with "Bootstrap failed: 5: Input/output error", and leave the service unloaded with nothing running. Bootstrap now polls `launchctl print` for up to 10 seconds, retries EIO with backoff (0.5s to 8s), and on final failure either restarts the still-loaded previous job or reports that the service is NOT loaded with the `launchctl bootstrap` command to fix it.
|
|
13
|
+
|
|
5
14
|
## [5.177.0] — 2026-09-24
|
|
6
15
|
|
|
7
16
|
### Added
|
|
@@ -46,7 +55,6 @@
|
|
|
46
55
|
- Bot LaunchAgents and systemd units, the Work Mesh daemon unit, the `hq daemon` unit, and the lanes and agent-kit units now name node by its upgrade-stable path (`/opt/homebrew/bin/node` or `/opt/homebrew/opt/<formula>/bin/node`, or nvm's `current` link) instead of the versioned `/opt/homebrew/Cellar/node/<version>/bin/node`. Before, `brew upgrade node` deleted that path and launchd could no longer start the bots. The stable path is used only when it resolves to the same node binary; otherwise the unit keeps the resolved path as before.
|
|
47
56
|
- Installed bot LaunchAgents that still name a versioned or missing node are repaired: `hq bot start` rewrites and reloads the agent, a bot started by launchd rewrites its own agent and reloads it from a detached shell before it takes any message, and `hq doctor` reports it (`bots.<name>.daemon-node`), with `hq doctor --fix` rewriting only the plist file and not reloading launchd. `hq bot list` and `hq bot status` flag a missing node binary. `hq mesh daemon install --if-stale` also reinstalls a same-version unit whose node path is missing or versioned.
|
|
48
57
|
|
|
49
|
-
|
|
50
58
|
## [5.172.0] — 2026-09-24
|
|
51
59
|
|
|
52
60
|
### Changed
|
|
@@ -83,7 +91,6 @@
|
|
|
83
91
|
|
|
84
92
|
- Switching the HQ session company (including `hq-session.sh set company_slug`) clears the Work Mesh project and task, posts `session_end` to the previous company, and holds a personal target as `needs_company`.
|
|
85
93
|
|
|
86
|
-
|
|
87
94
|
## [5.169.0] — 2026-09-24
|
|
88
95
|
|
|
89
96
|
### Fixed
|
package/dist/lib/bot/daemon.js
CHANGED
|
@@ -15,6 +15,7 @@ import * as path from "node:path";
|
|
|
15
15
|
import { spawn } from "node:child_process";
|
|
16
16
|
import { resolveHqBinary, resolveNodeBinary, preferStableHqBinary } from "../mesh/live/daemon/install.js";
|
|
17
17
|
import { botLogPath, launchdLabel } from "./paths.js";
|
|
18
|
+
import { reloadLaunchd } from "../service-manager/launchd.js";
|
|
18
19
|
import { nodeBinaryProblem, preferStableNodeBinary } from "../node-binary.js";
|
|
19
20
|
export function detectBotPlatform(platform = process.platform) {
|
|
20
21
|
if (platform === "darwin")
|
|
@@ -171,22 +172,17 @@ function bootoutLaunchd(p, deps) {
|
|
|
171
172
|
}
|
|
172
173
|
/**
|
|
173
174
|
* `launchctl bootout` returns before the job is actually gone; a `bootstrap`
|
|
174
|
-
* issued right away fails
|
|
175
|
-
*
|
|
176
|
-
*
|
|
175
|
+
* issued right away fails with EIO and the old job then finishes unloading,
|
|
176
|
+
* leaving nothing running. Shared with the mesh daemon and kit services:
|
|
177
|
+
* wait for the unload, then bootstrap with backoff.
|
|
177
178
|
*/
|
|
178
179
|
function rebootstrapLaunchd(p, deps) {
|
|
179
180
|
const launchctl = deps.launchctl;
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
for (let i = 0; i < 3 && !r.ok; i++) {
|
|
186
|
-
sleep(500);
|
|
187
|
-
r = launchctl(["bootstrap", launchdDomain(deps.uid), p.plistPath]);
|
|
188
|
-
}
|
|
189
|
-
return r;
|
|
181
|
+
const run = (_cmd, args) => {
|
|
182
|
+
const r = launchctl(args);
|
|
183
|
+
return { status: r.ok ? 0 : 1, stdout: r.stdout, stderr: r.stderr };
|
|
184
|
+
};
|
|
185
|
+
return reloadLaunchd(p.plistPath, run, deps.uid ?? os.userInfo().uid, { sleep: deps.sleepMs ?? blockingSleep });
|
|
190
186
|
}
|
|
191
187
|
function launchdLoaded(p, deps) {
|
|
192
188
|
if (!deps.launchctl)
|
|
@@ -397,17 +393,24 @@ export function installBotDaemon(p, deps = {}) {
|
|
|
397
393
|
if (deps.launchctl) {
|
|
398
394
|
// bootout first so a re-install picks up a changed plist.
|
|
399
395
|
const r = rebootstrapLaunchd(p, deps);
|
|
400
|
-
|
|
401
|
-
|
|
396
|
+
if (!r.ok) {
|
|
397
|
+
const detail = `launchctl bootstrap failed after ${r.bootstrapAttempts} attempts: ${(r.error ?? "").slice(0, 200)}`;
|
|
398
|
+
if (r.loaded) {
|
|
399
|
+
// The previous definition survived the bootout; keep it running.
|
|
400
|
+
deps.launchctl(["kickstart", "-k", `${launchdDomain(deps.uid)}/${p.label}`]);
|
|
401
|
+
}
|
|
402
402
|
return {
|
|
403
403
|
platform,
|
|
404
404
|
action: "install",
|
|
405
405
|
installed: true,
|
|
406
|
-
loaded:
|
|
406
|
+
loaded: r.loaded,
|
|
407
407
|
dest: p.plistPath,
|
|
408
|
-
message:
|
|
408
|
+
message: r.loaded
|
|
409
|
+
? `Wrote ${p.plistPath} but ${detail}. The previous definition of ${p.label} is still loaded and was restarted; the rewritten plist is not active yet.`
|
|
410
|
+
: `Wrote ${p.plistPath} but ${detail}. ${p.label} is NOT loaded. Load it with: launchctl bootstrap ${launchdDomain(deps.uid)} ${p.plistPath}`,
|
|
409
411
|
};
|
|
410
412
|
}
|
|
413
|
+
loaded = true;
|
|
411
414
|
}
|
|
412
415
|
return {
|
|
413
416
|
platform,
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* services are added. Every service-manager call goes through `run`, so tests
|
|
16
16
|
* never touch launchctl, systemctl or schtasks.
|
|
17
17
|
*/
|
|
18
|
-
import { type RunCommand } from "../service-manager/launchd.js";
|
|
18
|
+
import { type RunCommand, type SleepFn } from "../service-manager/launchd.js";
|
|
19
19
|
import type { MachineType } from "./machine-type.js";
|
|
20
20
|
export declare const DAEMON_LAUNCHD_LABEL = "ai.getindigo.hq-daemon";
|
|
21
21
|
export declare const DAEMON_SYSTEMD_UNIT = "hq-daemon.service";
|
|
@@ -63,6 +63,8 @@ export interface DaemonUnitResult {
|
|
|
63
63
|
}
|
|
64
64
|
export interface InstallDeps {
|
|
65
65
|
run: RunCommand;
|
|
66
|
+
/** Blocking sleep for the launchd reload wait (tests inject). */
|
|
67
|
+
sleep?: SleepFn;
|
|
66
68
|
}
|
|
67
69
|
export declare const ROOT_REFUSAL = "hq daemon runs its services as the machine's login user, never as root.";
|
|
68
70
|
/**
|
|
@@ -204,10 +204,10 @@ export function planDaemonUnit(host, options) {
|
|
|
204
204
|
function commandError(what, r) {
|
|
205
205
|
return new Error(`${what} failed: ${r.stderr.trim() || r.stdout.trim() || `exit ${r.status}`}`);
|
|
206
206
|
}
|
|
207
|
-
function activate(plan, host, run) {
|
|
207
|
+
function activate(plan, host, run, sleep) {
|
|
208
208
|
switch (plan.kind) {
|
|
209
209
|
case "launchd":
|
|
210
|
-
activateLaunchd(plan.unitPath, run, host.uid);
|
|
210
|
+
activateLaunchd(plan.unitPath, run, host.uid, { sleep });
|
|
211
211
|
return;
|
|
212
212
|
case "systemd-user":
|
|
213
213
|
activateSystemd(DAEMON_SYSTEMD_UNIT, run);
|
|
@@ -251,7 +251,7 @@ export function installDaemonUnit(host, options, deps) {
|
|
|
251
251
|
else
|
|
252
252
|
fs.writeFileSync(plan.unitPath, plan.rendered, { mode: plan.kind === "systemd-system" ? 0o644 : 0o600 });
|
|
253
253
|
try {
|
|
254
|
-
activate(plan, host, deps.run);
|
|
254
|
+
activate(plan, host, deps.run, deps.sleep);
|
|
255
255
|
return { kind: plan.kind, unitPath: plan.unitPath, installed: true, running: true };
|
|
256
256
|
}
|
|
257
257
|
catch (err) {
|
|
@@ -184,8 +184,8 @@ export function restartDaemonServiceIfInstalled(opts = {}) {
|
|
|
184
184
|
activateLaunchd(plist, asRunCommand(run), uid, { sleep: opts.sleep });
|
|
185
185
|
return { attempted: true, restarted: true };
|
|
186
186
|
}
|
|
187
|
-
catch {
|
|
188
|
-
return { attempted: true, restarted: false };
|
|
187
|
+
catch (err) {
|
|
188
|
+
return { attempted: true, restarted: false, message: err instanceof Error ? err.message : String(err) };
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
const result = run("launchctl", ["kickstart", "-k", target]);
|
|
@@ -24,6 +24,14 @@
|
|
|
24
24
|
* Network: harness claude-desktop | codex, source "transcript" on every event.
|
|
25
25
|
* Local state keeps transcriptMarker for channel-card / Atlas before network sync;
|
|
26
26
|
* localHarness is no longer written (harness carries claude-desktop on the wire).
|
|
27
|
+
*
|
|
28
|
+
* Hook-covered sessions (durable hook-written state, or a hook event within
|
|
29
|
+
* TRANSCRIPT_HOOK_COVER_MS) do not get synthesized session_start / turn_end —
|
|
30
|
+
* hooks own those. The watcher still tracks last activity (transcript mtime or
|
|
31
|
+
* hook events) and enqueues session_end after TRANSCRIPT_SESSION_END_MS of
|
|
32
|
+
* quiet so the channel work report has a trigger (US-050b). Resume resets the
|
|
33
|
+
* timer. A hook event within TRANSCRIPT_HOOK_COVER_MS of our own emit is the
|
|
34
|
+
* SessionEnd hook catching up and is not treated as resume.
|
|
27
35
|
*/
|
|
28
36
|
import * as fs from "node:fs";
|
|
29
37
|
import type { MembershipValidationResult } from "../../../work-context/config.js";
|
|
@@ -260,6 +268,14 @@ export declare class TranscriptWatcher {
|
|
|
260
268
|
/** Record that a hook path emitted an event for sessionId (daemon flush). */
|
|
261
269
|
noteHookEvent(sessionId: string, atMs?: number): void;
|
|
262
270
|
tick(): Promise<TranscriptWatchTickResult>;
|
|
271
|
+
/**
|
|
272
|
+
* Hook-covered sessions already emit start/turns. Still track last activity
|
|
273
|
+
* and enqueue session_end after TRANSCRIPT_SESSION_END_MS of quiet so the
|
|
274
|
+
* channel work report has a trigger (US-050b). Resume (mtime or a later hook
|
|
275
|
+
* event) resets the timer. A hook event shortly after our own emit is the
|
|
276
|
+
* SessionEnd hook catching up — do not treat it as resume.
|
|
277
|
+
*/
|
|
278
|
+
private tickHookCoveredQuietEnd;
|
|
263
279
|
/**
|
|
264
280
|
* Heavy ticks double the poll interval up to MAX; a quiet tick resets to base.
|
|
265
281
|
*/
|
|
@@ -24,6 +24,14 @@
|
|
|
24
24
|
* Network: harness claude-desktop | codex, source "transcript" on every event.
|
|
25
25
|
* Local state keeps transcriptMarker for channel-card / Atlas before network sync;
|
|
26
26
|
* localHarness is no longer written (harness carries claude-desktop on the wire).
|
|
27
|
+
*
|
|
28
|
+
* Hook-covered sessions (durable hook-written state, or a hook event within
|
|
29
|
+
* TRANSCRIPT_HOOK_COVER_MS) do not get synthesized session_start / turn_end —
|
|
30
|
+
* hooks own those. The watcher still tracks last activity (transcript mtime or
|
|
31
|
+
* hook events) and enqueues session_end after TRANSCRIPT_SESSION_END_MS of
|
|
32
|
+
* quiet so the channel work report has a trigger (US-050b). Resume resets the
|
|
33
|
+
* timer. A hook event within TRANSCRIPT_HOOK_COVER_MS of our own emit is the
|
|
34
|
+
* SessionEnd hook catching up and is not treated as resume.
|
|
27
35
|
*/
|
|
28
36
|
import * as fs from "node:fs";
|
|
29
37
|
import * as os from "node:os";
|
|
@@ -632,8 +640,7 @@ export class TranscriptWatcher {
|
|
|
632
640
|
lastHookEventAt: this.deps.lastHookEventAt,
|
|
633
641
|
readState: this.readState,
|
|
634
642
|
})) {
|
|
635
|
-
|
|
636
|
-
this.tracked.delete(d.path);
|
|
643
|
+
this.tickHookCoveredQuietEnd(d, nowMs, result);
|
|
637
644
|
continue;
|
|
638
645
|
}
|
|
639
646
|
let track = this.tracked.get(d.path);
|
|
@@ -719,6 +726,63 @@ export class TranscriptWatcher {
|
|
|
719
726
|
this.tickInFlight = false;
|
|
720
727
|
}
|
|
721
728
|
}
|
|
729
|
+
/**
|
|
730
|
+
* Hook-covered sessions already emit start/turns. Still track last activity
|
|
731
|
+
* and enqueue session_end after TRANSCRIPT_SESSION_END_MS of quiet so the
|
|
732
|
+
* channel work report has a trigger (US-050b). Resume (mtime or a later hook
|
|
733
|
+
* event) resets the timer. A hook event shortly after our own emit is the
|
|
734
|
+
* SessionEnd hook catching up — do not treat it as resume.
|
|
735
|
+
*/
|
|
736
|
+
tickHookCoveredQuietEnd(d, nowMs, result) {
|
|
737
|
+
result.skippedHookCovered += 1;
|
|
738
|
+
const hookAt = this.deps.lastHookEventAt?.get(d.sessionId);
|
|
739
|
+
const lastActivity = Math.max(d.mtimeMs, hookAt ?? 0);
|
|
740
|
+
let track = this.tracked.get(d.path);
|
|
741
|
+
if (!track) {
|
|
742
|
+
if (nowMs - lastActivity > TRANSCRIPT_FRESH_MS)
|
|
743
|
+
return;
|
|
744
|
+
track = {
|
|
745
|
+
path: d.path,
|
|
746
|
+
sessionId: d.sessionId,
|
|
747
|
+
kind: d.kind,
|
|
748
|
+
cwd: d.cwd,
|
|
749
|
+
harness: d.harness,
|
|
750
|
+
lastMtimeMs: d.mtimeMs,
|
|
751
|
+
lastChangeAtMs: lastActivity,
|
|
752
|
+
started: true,
|
|
753
|
+
ended: false,
|
|
754
|
+
pendingTurn: false,
|
|
755
|
+
seq: 0,
|
|
756
|
+
startRetryAttempts: 0,
|
|
757
|
+
nextStartRetryAtMs: 0,
|
|
758
|
+
};
|
|
759
|
+
this.tracked.set(d.path, track);
|
|
760
|
+
}
|
|
761
|
+
if (d.mtimeMs > track.lastMtimeMs) {
|
|
762
|
+
track.lastMtimeMs = d.mtimeMs;
|
|
763
|
+
track.lastChangeAtMs = nowMs;
|
|
764
|
+
if (track.ended)
|
|
765
|
+
track.ended = false;
|
|
766
|
+
}
|
|
767
|
+
if (hookAt !== undefined) {
|
|
768
|
+
const resumeFromHook = !track.ended ||
|
|
769
|
+
track.endedAtMs === undefined ||
|
|
770
|
+
hookAt - track.endedAtMs >= TRANSCRIPT_HOOK_COVER_MS;
|
|
771
|
+
if (resumeFromHook && hookAt > track.lastChangeAtMs) {
|
|
772
|
+
track.lastChangeAtMs = hookAt;
|
|
773
|
+
if (track.ended)
|
|
774
|
+
track.ended = false;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (track.started &&
|
|
778
|
+
!track.ended &&
|
|
779
|
+
nowMs - track.lastChangeAtMs >= TRANSCRIPT_SESSION_END_MS) {
|
|
780
|
+
this.emitKind(track, "session_end", nowMs);
|
|
781
|
+
track.ended = true;
|
|
782
|
+
track.endedAtMs = nowMs;
|
|
783
|
+
result.ended += 1;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
722
786
|
/**
|
|
723
787
|
* Heavy ticks double the poll interval up to MAX; a quiet tick resets to base.
|
|
724
788
|
*/
|
|
@@ -17,10 +17,14 @@ export type RunCommand = (cmd: string, args: string[]) => {
|
|
|
17
17
|
stderr: string;
|
|
18
18
|
};
|
|
19
19
|
export type SleepFn = (ms: number) => void;
|
|
20
|
+
/** How long to wait for a booted-out job to leave launchd before bootstrapping anyway. */
|
|
21
|
+
export declare const LAUNCHD_UNLOAD_DEADLINE_MS = 10000;
|
|
22
|
+
/** Interval between `launchctl print` polls while the old job unloads. */
|
|
23
|
+
export declare const LAUNCHD_UNLOAD_POLL_MS = 250;
|
|
24
|
+
/** Backoff before each bootstrap retry on EIO (one retry per entry). */
|
|
25
|
+
export declare const LAUNCHD_BOOTSTRAP_BACKOFF_MS: readonly number[];
|
|
20
26
|
/** Bootstrap attempts after bootout (first try + EIO retries). */
|
|
21
|
-
export declare const LAUNCHD_BOOTSTRAP_ATTEMPTS
|
|
22
|
-
/** Delay between bootstrap retries while the old job is still exiting. */
|
|
23
|
-
export declare const LAUNCHD_BOOTSTRAP_RETRY_MS = 1000;
|
|
27
|
+
export declare const LAUNCHD_BOOTSTRAP_ATTEMPTS: number;
|
|
24
28
|
export declare const defaultRunCommand: RunCommand;
|
|
25
29
|
export declare function launchdServiceTarget(plistPath: string, uid?: number): string;
|
|
26
30
|
/** launchd returns EIO (5) when bootstrap races the previous job's exit. */
|
|
@@ -38,13 +42,46 @@ export declare function launchdPlistProgramArguments(plist: string): string[];
|
|
|
38
42
|
export declare function parseLaunchctlPrintArguments(stdout: string): string[];
|
|
39
43
|
export declare function launchdProgramArgumentsDiffer(loaded: string[], plist: string[]): boolean;
|
|
40
44
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
|
|
45
|
+
* Poll `launchctl print <target>` until the label is gone. Returns false when
|
|
46
|
+
* it is still loaded at the deadline. Elapsed time is counted in slept
|
|
47
|
+
* intervals so an injected sleep keeps tests deterministic.
|
|
48
|
+
*/
|
|
49
|
+
export declare function waitForLaunchdUnload(target: string, run: RunCommand, opts?: {
|
|
50
|
+
sleep?: SleepFn;
|
|
51
|
+
deadlineMs?: number;
|
|
52
|
+
}): boolean;
|
|
53
|
+
export interface LaunchdReloadResult {
|
|
54
|
+
/** bootstrap of the plist succeeded. */
|
|
55
|
+
ok: boolean;
|
|
56
|
+
/** The label is present in launchd after the reload (new or previous definition). */
|
|
57
|
+
loaded: boolean;
|
|
58
|
+
/** The previous job was still loaded when the unload deadline passed. */
|
|
59
|
+
unloadTimedOut: boolean;
|
|
60
|
+
bootstrapAttempts: number;
|
|
61
|
+
/** Last bootstrap error when !ok. */
|
|
62
|
+
error?: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* bootout, wait for the label to actually leave launchd, then bootstrap with
|
|
66
|
+
* backoff on EIO. `launchctl bootout` returns before launchd finishes tearing
|
|
67
|
+
* the job down, and a bootstrap issued in that window fails with
|
|
68
|
+
* "Bootstrap failed: 5: Input/output error" — after which the old job
|
|
69
|
+
* finishes unloading and nothing is left running.
|
|
70
|
+
*/
|
|
71
|
+
export declare function reloadLaunchd(plistPath: string, run: RunCommand, uid?: number, opts?: {
|
|
72
|
+
sleep?: SleepFn;
|
|
73
|
+
unloadDeadlineMs?: number;
|
|
74
|
+
}): LaunchdReloadResult;
|
|
75
|
+
/**
|
|
76
|
+
* (Re)load a plist so launchd picks up a rewritten ProgramArguments, then
|
|
77
|
+
* kickstart it. Never leaves the service silently unloaded: when bootstrap
|
|
78
|
+
* keeps failing but the previous job is still loaded, that job is restarted
|
|
79
|
+
* and the error says so; when nothing is loaded, the error says the service
|
|
80
|
+
* is NOT loaded and gives the command to load it.
|
|
45
81
|
*/
|
|
46
82
|
export declare function activateLaunchd(plistPath: string, run: RunCommand, uid?: number, opts?: {
|
|
47
83
|
sleep?: SleepFn;
|
|
84
|
+
unloadDeadlineMs?: number;
|
|
48
85
|
}): void;
|
|
49
86
|
export declare function deactivateLaunchd(plistPath: string, run: RunCommand, uid?: number): void;
|
|
50
87
|
export declare function launchdIsLoaded(label: string, run: RunCommand, uid?: number): boolean;
|
|
@@ -60,10 +60,14 @@ ${envXml}
|
|
|
60
60
|
</plist>
|
|
61
61
|
`;
|
|
62
62
|
}
|
|
63
|
+
/** How long to wait for a booted-out job to leave launchd before bootstrapping anyway. */
|
|
64
|
+
export const LAUNCHD_UNLOAD_DEADLINE_MS = 10_000;
|
|
65
|
+
/** Interval between `launchctl print` polls while the old job unloads. */
|
|
66
|
+
export const LAUNCHD_UNLOAD_POLL_MS = 250;
|
|
67
|
+
/** Backoff before each bootstrap retry on EIO (one retry per entry). */
|
|
68
|
+
export const LAUNCHD_BOOTSTRAP_BACKOFF_MS = [500, 1000, 2000, 4000, 8000];
|
|
63
69
|
/** Bootstrap attempts after bootout (first try + EIO retries). */
|
|
64
|
-
export const LAUNCHD_BOOTSTRAP_ATTEMPTS =
|
|
65
|
-
/** Delay between bootstrap retries while the old job is still exiting. */
|
|
66
|
-
export const LAUNCHD_BOOTSTRAP_RETRY_MS = 1000;
|
|
70
|
+
export const LAUNCHD_BOOTSTRAP_ATTEMPTS = LAUNCHD_BOOTSTRAP_BACKOFF_MS.length + 1;
|
|
67
71
|
export const defaultRunCommand = (cmd, args) => {
|
|
68
72
|
const r = spawnSync(cmd, args, { encoding: "utf8" });
|
|
69
73
|
return {
|
|
@@ -127,25 +131,76 @@ export function launchdProgramArgumentsDiffer(loaded, plist) {
|
|
|
127
131
|
return loaded.some((value, i) => value !== plist[i]);
|
|
128
132
|
}
|
|
129
133
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* exited. kickstart then starts the newly loaded program.
|
|
134
|
+
* Poll `launchctl print <target>` until the label is gone. Returns false when
|
|
135
|
+
* it is still loaded at the deadline. Elapsed time is counted in slept
|
|
136
|
+
* intervals so an injected sleep keeps tests deterministic.
|
|
134
137
|
*/
|
|
135
|
-
export function
|
|
138
|
+
export function waitForLaunchdUnload(target, run, opts) {
|
|
139
|
+
const sleep = opts?.sleep ?? blockingSleep;
|
|
140
|
+
const deadline = opts?.deadlineMs ?? LAUNCHD_UNLOAD_DEADLINE_MS;
|
|
141
|
+
for (let waited = 0; run("launchctl", ["print", target]).status === 0; waited += LAUNCHD_UNLOAD_POLL_MS) {
|
|
142
|
+
if (waited >= deadline)
|
|
143
|
+
return false;
|
|
144
|
+
sleep(LAUNCHD_UNLOAD_POLL_MS);
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* bootout, wait for the label to actually leave launchd, then bootstrap with
|
|
150
|
+
* backoff on EIO. `launchctl bootout` returns before launchd finishes tearing
|
|
151
|
+
* the job down, and a bootstrap issued in that window fails with
|
|
152
|
+
* "Bootstrap failed: 5: Input/output error" — after which the old job
|
|
153
|
+
* finishes unloading and nothing is left running.
|
|
154
|
+
*/
|
|
155
|
+
export function reloadLaunchd(plistPath, run, uid, opts) {
|
|
136
156
|
const domain = guiDomain(uid);
|
|
137
157
|
const target = launchdServiceTarget(plistPath, uid);
|
|
138
|
-
run("launchctl", ["bootout", target]);
|
|
139
158
|
const sleep = opts?.sleep ?? blockingSleep;
|
|
159
|
+
// bootout is best-effort: "not loaded" on a first install is fine.
|
|
160
|
+
run("launchctl", ["bootout", target]);
|
|
161
|
+
const unloaded = waitForLaunchdUnload(target, run, { sleep, deadlineMs: opts?.unloadDeadlineMs });
|
|
140
162
|
let last = run("launchctl", ["bootstrap", domain, plistPath]);
|
|
141
|
-
|
|
142
|
-
|
|
163
|
+
let attempts = 1;
|
|
164
|
+
for (const backoff of LAUNCHD_BOOTSTRAP_BACKOFF_MS) {
|
|
165
|
+
if (last.status === 0 || !isRetryableLaunchdBootstrap(last))
|
|
166
|
+
break;
|
|
167
|
+
sleep(backoff);
|
|
143
168
|
last = run("launchctl", ["bootstrap", domain, plistPath]);
|
|
169
|
+
attempts++;
|
|
170
|
+
}
|
|
171
|
+
if (last.status === 0) {
|
|
172
|
+
return { ok: true, loaded: true, unloadTimedOut: !unloaded, bootstrapAttempts: attempts };
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
loaded: run("launchctl", ["print", target]).status === 0,
|
|
177
|
+
unloadTimedOut: !unloaded,
|
|
178
|
+
bootstrapAttempts: attempts,
|
|
179
|
+
error: last.stderr.trim() || last.stdout.trim() || `exit ${last.status}`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* (Re)load a plist so launchd picks up a rewritten ProgramArguments, then
|
|
184
|
+
* kickstart it. Never leaves the service silently unloaded: when bootstrap
|
|
185
|
+
* keeps failing but the previous job is still loaded, that job is restarted
|
|
186
|
+
* and the error says so; when nothing is loaded, the error says the service
|
|
187
|
+
* is NOT loaded and gives the command to load it.
|
|
188
|
+
*/
|
|
189
|
+
export function activateLaunchd(plistPath, run, uid, opts) {
|
|
190
|
+
const domain = guiDomain(uid);
|
|
191
|
+
const target = launchdServiceTarget(plistPath, uid);
|
|
192
|
+
const r = reloadLaunchd(plistPath, run, uid, opts);
|
|
193
|
+
if (r.ok) {
|
|
194
|
+
run("launchctl", ["kickstart", "-k", target]);
|
|
195
|
+
return;
|
|
144
196
|
}
|
|
145
|
-
|
|
146
|
-
|
|
197
|
+
const detail = `launchctl bootstrap failed for ${plistPath} after ${r.bootstrapAttempts} attempts: ${r.error}`;
|
|
198
|
+
if (r.loaded) {
|
|
199
|
+
run("launchctl", ["kickstart", "-k", target]);
|
|
200
|
+
throw new Error(`${detail}. The previous definition of ${target} is still loaded and was restarted; ` +
|
|
201
|
+
`the rewritten plist is not active yet. Retry, or run: launchctl bootout ${target} && launchctl bootstrap ${domain} ${plistPath}`);
|
|
147
202
|
}
|
|
148
|
-
run
|
|
203
|
+
throw new Error(`${detail}. ${target} is NOT loaded and will not run until it is. Load it with: launchctl bootstrap ${domain} ${plistPath}`);
|
|
149
204
|
}
|
|
150
205
|
export function deactivateLaunchd(plistPath, run, uid) {
|
|
151
206
|
run("launchctl", ["bootout", guiDomain(uid), plistPath]);
|