Skip to content

Commit c3958bb

Browse files
roodboiclaude
andcommitted
fix(daemon): repair virtual plist paths, sweep orphans, launchd-exclusive start
Root cause of 'daemon starts then exits' on compiled-binary installs: the launchd plist captured process.argv[1] from inside the compiled Bun binary — the virtual /$bunfs/root/hack path, which passes pathExists inside that process but is unexecutable by launchd (exit status 78, flapping forever). Reproduced and fixed live on this machine. The bin-path resolver now rejects virtual paths and prefers stable locations; daemon start self-repairs invalid plists; daemons that outlived their pid file (invisible to status/clear/start) are detected and swept; an incompatible running daemon is replaced instead of reported as success; and when launchd manages the service, start goes through launchd exclusively — never a bare spawn beside it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 143a857 commit c3958bb

4 files changed

Lines changed: 335 additions & 10 deletions

File tree

src/commands/daemon.ts

Lines changed: 119 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,16 @@ import {
1414
installLaunchdService,
1515
kickstartLaunchdService,
1616
type LaunchdServiceStatus,
17+
repairLaunchdProgramIfInvalid,
1718
uninstallLaunchdService,
1819
} from "../daemon/launchd.ts";
1920
import { type DaemonPaths, resolveDaemonPaths } from "../daemon/paths.ts";
20-
import { removeFileIfExists, waitForProcessExit } from "../daemon/process.ts";
21+
import {
22+
findOrphanDaemonProcesses,
23+
removeFileIfExists,
24+
terminateOrphanDaemonProcesses,
25+
waitForProcessExit,
26+
} from "../daemon/process.ts";
2127
import { runDaemon } from "../daemon/server.ts";
2228
import {
2329
buildDaemonRepairMessage,
@@ -220,11 +226,38 @@ async function handleDaemonStart({
220226
const paths = resolveDaemonPaths({});
221227
const status = await readDaemonStatus({ paths });
222228

223-
if (status.running) {
224-
logger.success({
225-
message: `hackd already running (pid ${status.pid ?? "unknown"})`,
229+
if (status.running && status.pid !== null) {
230+
const api = await checkDaemonApi({
231+
socketExists: status.socketExists,
232+
paths,
226233
});
227-
return 0;
234+
if (api.reachable && !api.compatible) {
235+
// An incompatible daemon (usually a pre-upgrade binary) must be
236+
// replaced, not reported as success — leaving it running is how
237+
// machines end up with doctor/status contradictions.
238+
logger.warn({
239+
message: `Replacing incompatible hackd (pid ${status.pid})`,
240+
});
241+
await stopDaemonProcess({ pid: status.pid, paths });
242+
} else {
243+
logger.success({
244+
message: `hackd already running (pid ${status.pid})`,
245+
});
246+
return 0;
247+
}
248+
}
249+
250+
// Daemons that outlived their pid file (e.g. a launchd-managed instance
251+
// surviving a manual clear/restart) hold the API socket invisibly and
252+
// make every freshly spawned daemon exit cleanly. Sweep them first.
253+
const orphans = await findOrphanDaemonProcesses({
254+
trackedPid: status.pid,
255+
});
256+
if (orphans.length > 0) {
257+
logger.warn({
258+
message: `Stopping orphaned hackd process(es) not tracked by the pid file: ${orphans.join(", ")}`,
259+
});
260+
await terminateOrphanDaemonProcesses({ pids: orphans });
228261
}
229262

230263
await removeFileIfExists({ path: paths.socketPath });
@@ -235,6 +268,43 @@ async function handleDaemonStart({
235268
return 0;
236269
}
237270

271+
// When launchd manages the daemon, start through launchd — spawning a
272+
// bare process next to a loaded agent means two process managers fight
273+
// over one socket and pid file.
274+
const launchdStatus = await resolveLaunchdStatus({ paths });
275+
if (launchdStatus?.loaded) {
276+
const repair = await repairLaunchdProgramIfInvalid({ paths });
277+
if (repair === "repaired") {
278+
logger.warn({
279+
message:
280+
"Repaired launchd service: its program path was invalid (stale or compiled-binary virtual path)",
281+
});
282+
}
283+
// launchd owns the daemon: never spawn a bare process next to it —
284+
// two process managers for one socket is how machines end up with
285+
// start-then-exit flapping and stale-pid contradictions.
286+
const kick = await kickstartLaunchdService();
287+
if (!kick.ok) {
288+
logger.error({
289+
message: `launchd kickstart failed: ${kick.error ?? "unknown error"} | Check: hack daemon logs --tail 200`,
290+
});
291+
return 1;
292+
}
293+
const startedViaLaunchd = await waitForDaemonStart({
294+
paths,
295+
timeoutMs: 8000,
296+
});
297+
if (startedViaLaunchd) {
298+
logger.success({ message: "hackd started (launchd)" });
299+
return 0;
300+
}
301+
logger.warn({
302+
message:
303+
"launchd kickstarted hackd but it did not report ready yet | Check: hack daemon logs --tail 200",
304+
});
305+
return 1;
306+
}
307+
238308
const invocation = await resolveHackInvocation();
239309
const cmd = [...invocation.args, "daemon", "start", "--foreground"];
240310
const proc = Bun.spawn([invocation.bin, ...cmd], {
@@ -256,6 +326,31 @@ async function handleDaemonStart({
256326
return 0;
257327
}
258328

329+
async function stopDaemonProcess(opts: {
330+
readonly pid: number;
331+
readonly paths: DaemonPaths;
332+
}): Promise<void> {
333+
try {
334+
process.kill(opts.pid, "SIGTERM");
335+
} catch {
336+
// Already gone.
337+
}
338+
const exited = await waitForProcessExit({
339+
pid: opts.pid,
340+
timeoutMs: 2000,
341+
pollMs: 200,
342+
});
343+
if (!exited) {
344+
try {
345+
process.kill(opts.pid, "SIGKILL");
346+
} catch {
347+
// Already gone.
348+
}
349+
}
350+
await removeFileIfExists({ path: opts.paths.pidPath });
351+
await removeFileIfExists({ path: opts.paths.socketPath });
352+
}
353+
259354
async function handleDaemonStop({
260355
args: _args,
261356
}: {
@@ -506,11 +601,26 @@ async function handleDaemonClear({
506601
return 1;
507602
}
508603

604+
const orphans = await findOrphanDaemonProcesses({
605+
trackedPid: status.pid,
606+
});
607+
if (orphans.length > 0) {
608+
logger.warn({
609+
message: `Stopping orphaned hackd process(es): ${orphans.join(", ")}`,
610+
});
611+
await terminateOrphanDaemonProcesses({ pids: orphans });
612+
}
613+
509614
const pidExists = await pathExists(paths.pidPath);
510615
const socketExists = await pathExists(paths.socketPath);
511616

512617
if (!(pidExists || socketExists)) {
513-
logger.info({ message: "No stale hackd state found" });
618+
logger.info({
619+
message:
620+
orphans.length > 0
621+
? "Cleared orphaned hackd process(es); no stale files found"
622+
: "No stale hackd state found",
623+
});
514624
return 0;
515625
}
516626

@@ -549,10 +659,12 @@ async function handleDaemonRestart({
549659

550660
async function waitForDaemonStart({
551661
paths,
662+
timeoutMs,
552663
}: {
553664
readonly paths: ReturnType<typeof resolveDaemonPaths>;
665+
readonly timeoutMs?: number;
554666
}): Promise<boolean> {
555-
const deadline = Date.now() + 2000;
667+
const deadline = Date.now() + (timeoutMs ?? 2000);
556668
while (Date.now() < deadline) {
557669
const status = await readDaemonStatus({ paths });
558670
if (status.running && status.socketExists) {

src/daemon/launchd.ts

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,12 +167,16 @@ async function resolveLaunchdHackBinPath(opts: {
167167
readonly args: readonly string[];
168168
};
169169
}): Promise<string | null> {
170+
// Stable locations first: versioned paths (homebrew Cellar) rot on
171+
// upgrade, and argv paths inside a compiled Bun binary report the
172+
// virtual /$bunfs mount, which only exists inside that process —
173+
// launchd exec'ing it fails forever with status 78.
170174
const candidates = [
171-
process.argv[1] ?? null,
175+
resolve(resolveGlobalHackDir(), "bin", "hack"),
176+
await findExecutableInPath("hack"),
172177
opts.invocation.args[0] ?? null,
173178
opts.invocation.bin,
174-
await findExecutableInPath("hack"),
175-
resolve(resolveGlobalHackDir(), "bin", "hack"),
179+
process.argv[1] ?? null,
176180
];
177181

178182
for (const raw of candidates) {
@@ -192,13 +196,78 @@ function normalizeHackExecutablePath(raw: string | null): string | null {
192196
if (!trimmed) {
193197
return null;
194198
}
199+
if (isVirtualExecutablePath(trimmed)) {
200+
return null;
201+
}
195202
const base = basename(trimmed).toLowerCase();
196203
if (!(base === "hack" || base.startsWith("hack-"))) {
197204
return null;
198205
}
199206
return trimmed;
200207
}
201208

209+
/**
210+
* Paths inside a compiled Bun binary's virtual filesystem. They pass
211+
* pathExists from INSIDE the same process but no other process (launchd
212+
* included) can exec them.
213+
*/
214+
export function isVirtualExecutablePath(path: string): boolean {
215+
return path.startsWith("/$bunfs/") || path.includes("~BUN");
216+
}
217+
218+
/**
219+
* Extracts ProgramArguments[0] from rendered launchd plist text.
220+
*/
221+
export function extractLaunchdProgramPath(opts: {
222+
readonly plistText: string;
223+
}): string | null {
224+
const anchor = opts.plistText.indexOf("<key>ProgramArguments</key>");
225+
if (anchor < 0) {
226+
return null;
227+
}
228+
const match = opts.plistText.slice(anchor).match(/<string>([^<]+)<\/string>/);
229+
return match?.[1] ?? null;
230+
}
231+
232+
export type LaunchdProgramRepairResult =
233+
| "ok"
234+
| "repaired"
235+
| "not-installed"
236+
| "failed";
237+
238+
/**
239+
* Repairs an installed launchd plist whose program path is invalid — a
240+
* compiled-binary virtual path (/$bunfs) or a binary that no longer exists
241+
* (e.g. an upgraded homebrew Cellar path). Broken plists leave launchd
242+
* flapping with exit status 78 and the daemon unable to stay up.
243+
*/
244+
export async function repairLaunchdProgramIfInvalid(opts: {
245+
readonly paths: DaemonPaths;
246+
}): Promise<LaunchdProgramRepairResult> {
247+
const plistText = await readTextFile(opts.paths.launchdPlistPath);
248+
if (plistText === null) {
249+
return "not-installed";
250+
}
251+
const program = extractLaunchdProgramPath({ plistText });
252+
const valid =
253+
program !== null &&
254+
!isVirtualExecutablePath(program) &&
255+
(await pathExists(program));
256+
if (valid) {
257+
return "ok";
258+
}
259+
260+
const result = await installLaunchdService({
261+
paths: opts.paths,
262+
config: {
263+
installed: true,
264+
runAtLoad: plistText.includes("<key>RunAtLoad</key>\n <true/>"),
265+
guiSessionOnly: plistText.includes("LimitLoadToSessionType"),
266+
},
267+
});
268+
return result.ok ? "repaired" : "failed";
269+
}
270+
202271
export interface LaunchdUninstallResult {
203272
readonly ok: boolean;
204273
readonly error?: string;

src/daemon/process.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,83 @@ export async function waitForProcessExit({
6666
function sleep({ ms }: { readonly ms: number }): Promise<void> {
6767
return new Promise((resolve) => setTimeout(resolve, ms));
6868
}
69+
70+
const DAEMON_COMMAND_MARKER = "daemon start --foreground";
71+
72+
/**
73+
* Finds hackd processes that the pid file does not track ("orphans").
74+
*
75+
* Orphans appear when a daemon keeps running after its pid/socket files were
76+
* replaced or cleared (e.g. a launchd-managed instance surviving a manual
77+
* restart). They are invisible to status/start/clear, which only consult the
78+
* pid file — the root cause of "daemon starts then exits" contradictions:
79+
* the orphan holds the API while every newly spawned daemon exits.
80+
*
81+
* @param opts.trackedPid - pid currently recorded in the pid file, if any.
82+
* @param opts.psLines - injectable `ps -axo pid=,command=` lines for tests.
83+
*/
84+
export async function findOrphanDaemonProcesses(opts: {
85+
readonly trackedPid: number | null;
86+
readonly psLines?: readonly string[];
87+
}): Promise<readonly number[]> {
88+
const lines = opts.psLines ?? (await listProcessTable());
89+
const orphans: number[] = [];
90+
for (const line of lines) {
91+
const trimmed = line.trim();
92+
const spaceIdx = trimmed.indexOf(" ");
93+
if (spaceIdx <= 0) {
94+
continue;
95+
}
96+
const pid = Number.parseInt(trimmed.slice(0, spaceIdx), 10);
97+
const command = trimmed.slice(spaceIdx + 1);
98+
if (!Number.isFinite(pid) || pid <= 0) {
99+
continue;
100+
}
101+
if (!command.includes(DAEMON_COMMAND_MARKER)) {
102+
continue;
103+
}
104+
if (pid === opts.trackedPid || pid === process.pid) {
105+
continue;
106+
}
107+
orphans.push(pid);
108+
}
109+
return orphans;
110+
}
111+
112+
async function listProcessTable(): Promise<readonly string[]> {
113+
const proc = Bun.spawn(["ps", "-axo", "pid=,command="], {
114+
stdout: "pipe",
115+
stderr: "ignore",
116+
stdin: "ignore",
117+
});
118+
const text = await new Response(proc.stdout).text();
119+
await proc.exited;
120+
return text.split("\n");
121+
}
122+
123+
/**
124+
* Terminates orphan daemon processes (SIGTERM, bounded wait). Returns the
125+
* pids that were actually terminated.
126+
*/
127+
export async function terminateOrphanDaemonProcesses(opts: {
128+
readonly pids: readonly number[];
129+
readonly timeoutMs?: number;
130+
}): Promise<readonly number[]> {
131+
const terminated: number[] = [];
132+
for (const pid of opts.pids) {
133+
try {
134+
process.kill(pid, "SIGTERM");
135+
} catch {
136+
continue;
137+
}
138+
const exited = await waitForProcessExit({
139+
pid,
140+
timeoutMs: opts.timeoutMs ?? 3000,
141+
pollMs: 100,
142+
});
143+
if (exited) {
144+
terminated.push(pid);
145+
}
146+
}
147+
return terminated;
148+
}

0 commit comments

Comments
 (0)