Skip to content
13 changes: 13 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,19 @@ Matching a process name is insufficient: a different `HOME` or `HACK_HOME` may
own another daemon. If socket ownership cannot be established, automatic orphan
termination is skipped. Scripted/noninteractive commands do not autostart the
daemon; after an explicit stop, use `hack daemon start` to restore cached queries.
`HACK_DAEMON_AUTOSTART_DISABLE=1` turns autostart off for interactive commands
too. Candidate runs never autostart: `hack-next` and the bundled `hack-v5`
entrypoint set `HACK_CANDIDATE_ENTRYPOINT`, and `hack-v5` also sets the opt-out.
Autostart would start whatever hackd the host resolves (the launchd plist or the
first `hack` on PATH), which is the stable daemon, not the candidate. Under
either variable, `hack global up` also skips its `daemon.autoStart` step (the
`hack daemon start` it runs through the first `hack` on PATH), and macOS
`hack global install` skips its offer to run `hack daemon install`; both log the
skip. `hack doctor --fix` also skips its hackd repair (restart an incompatible
hackd, clear stale pid/socket files, start hackd), in interactive and
non-interactive mode alike, because each step runs the first `hack` on PATH. It
asks nothing, notes the skip with the reason, and names the command to run
yourself, for example `hack daemon clear && hack daemon start`.

launchd and isolated state roots: on macOS the hackd launchd service is one
per-user service (`gui/<uid>/dance.hack.hackd`, plist in
Expand Down
34 changes: 27 additions & 7 deletions docs/guides/candidate-install.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,23 +128,43 @@ home and uses your own Hack home.
A Compose candidate run uses the same `~/.hack` as stable Hack, so the stable
`hackd` daemon must not run while the candidate does. Stopping it once is not
enough: stable `hack global up` (v4.2.0) starts `hackd` again whenever it is
stopped, because the stable `daemon.autoStart` setting is on by default. Other
stable commands that talk to `hackd` can also start it again.
stopped, because the stable `daemon.autoStart` setting is on by default. Stable
`hack doctor --fix` (v4.2.0) also starts a stopped `hackd`: it asks first in a
terminal, and starts it without asking in a non-interactive run. Other
stable commands that talk to `hackd` can also start it again. Newer candidate
builds turn off daemon autostart in `hack-next` and the bundled `hack-v5`
entrypoint, so the candidate does not start the stable `hackd` on its own, not
even from a candidate `global up`, `global install` or `doctor --fix`. A
candidate `hack doctor --fix` skips its `hackd` repair without asking, in a
terminal or not, and prints the reason and the command to run yourself, such as
`hack daemon start`. That command runs the stable CLI, so do not run it during a
Compose candidate run. Older candidate bundles can start `hackd`, so use this
order with every bundle.

Use this order:

```sh
hack global up # stable: global services up; also restarts hackd
hack daemon stop # stable: stop hackd after global up, not before
hack doctor # stable, optional: run any doctor or --fix here
hack daemon stop # stable: stop hackd after global up and doctor
pgrep -fl "hack daemon start" # must print nothing
HACK_RUNTIME_BACKEND=compose "$HOME/.hack-next/bin/hack-next" up --path /absolute/project
```

1. Run stable `hack global up` first, so the global Caddy and CoreDNS services run.
2. Then run stable `hack daemon stop`.
3. Then check with `pgrep -fl "hack daemon start"`. If it lists a process, a
stable `hackd` is still running, for example one that its launchd service
restarted. Do not start the candidate until the check prints nothing.
2. If you want a stable `hack doctor` or `hack doctor --fix` check, run it now,
before you stop `hackd`. After you stop `hackd`, do not run stable
`hack doctor --fix` again: it asks "Start hackd now?" in a terminal and
starts `hackd` without asking in a non-interactive run.
3. Then run stable `hack daemon stop`.
4. Then check with `pgrep -fl "hack daemon start"`. If it lists a process, a
stable `hackd` is still running, usually one that its per-user launchd
service (`dance.hack.hackd`) started again. Run stable `hack daemon uninstall`
to unload that service and remove its plist, or run
`launchctl bootout "gui/$(id -u)/dance.hack.hackd"` to unload it only. Then run
`hack daemon stop` again and repeat the check. Do not start the candidate until
the check prints nothing. If you uninstalled the service, run stable
`hack daemon install` after the candidate run to restore it.

Run no stable `hack` command against the same `~/.hack` while the candidate run
is active, including `hack global`, `hack status`, `hack doctor` and project
Expand Down
12 changes: 9 additions & 3 deletions docs/guides/native-candidate.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,15 @@ with your own Hack home and existing global routing services. It needs no
`HACK_NATIVE_HOME` and removes the native selectors. Other values refuse with exit
code 64. The [candidate installer guide](https://github.com/hack-dance/hack/blob/next/docs/guides/candidate-install.md#test-both-backends)
describes Compose testing and its shared-state limits: run stable `hack global up`,
then stable `hack daemon stop`, confirm `pgrep -fl "hack daemon start"` prints
nothing, and run no stable `hack` command while the candidate uses your Hack home.
Stable `hack global up` (v4.2.0) starts a stopped `hackd` again. Do not commit the
then any stable `hack doctor` or `hack doctor --fix`, then stable `hack daemon stop`,
confirm `pgrep -fl "hack daemon start"` prints nothing, and run no stable `hack`
command while the candidate uses your Hack home. Stable `hack global up` (v4.2.0)
starts a stopped `hackd` again, and so does stable `hack doctor --fix` (it asks
first only in a terminal). If the check lists a process, the guide gives the
launchd remedy. The entrypoint turns off daemon autostart in newer builds, so the
candidate does not start the stable `hackd` on its own: a candidate
`hack doctor --fix` skips its `hackd` repair without asking and prints the
command to run yourself, which runs the stable CLI. Do not commit the
project's generated `.hack/.internal/` directory; newer generators rewrite files
such as `compose.override.yml`. The entrypoint preserves arguments, exit codes and
signals through `exec`, and never replaces the installed `hack`. Project-specific
Expand Down
5 changes: 3 additions & 2 deletions docs/guides/v5-dogfooding.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ These commands do not establish that an arbitrary v4 project can run natively.
stable `hack` must not run against the same home at the same time. For a Compose
run, use the order in
[Stop stable hackd before a Compose run](candidate-install.md#stop-stable-hackd-before-a-compose-run):
stable `hack global up`, then `hack daemon stop`, then confirm
`pgrep -fl "hack daemon start"` prints nothing. Keep the project's
stable `hack global up`, then any stable `hack doctor` or `hack doctor --fix`,
then `hack daemon stop`, then confirm `pgrep -fl "hack daemon start"` prints
nothing; the guide gives the remedy when it lists a process. Keep the project's
`.hack/.internal/` directory out of Git.
3. Exercise the project's supported command form: foreground start, readiness,
status, finite logs, noninteractive exec, retaining stop and restart. Exercise
Expand Down
4 changes: 4 additions & 0 deletions scripts/hack-v5.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ if [ ! -x "$bundle/hack-cli" ] || [ ! -x "$bundle/hack-native" ]; then
fi
# Candidate CLIs refuse self-update on either backend.
export HACK_CANDIDATE_ENTRYPOINT=hack-v5
# Candidate runs never start hackd implicitly: autostart would start the
# stable hackd (launchd plist or the `hack` on PATH), on Compose beside the
# candidate on the same Hack home (HACK-1368).
export HACK_DAEMON_AUTOSTART_DISABLE=1
if [ "$backend" = native ]; then
export HACK_RUNTIME_BACKEND=native
export HACK_NATIVE_BINARY="$bundle/hack-native"
Expand Down
6 changes: 4 additions & 2 deletions src/backends/native-authored-project-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,8 +451,10 @@ async function retireAttempt(opts: {

/**
* HACK-1272: a consumer that refused before journal reservation (for example
* `native_graph_admission` for a non-content-ID image) leaves no receipt, so
* Removed retirement can never succeed. Retire the exact start intent and
* a `native_graph_canceled` cancel, a project source or review that changed
* after planning, or an unreachable engine) leaves no receipt, so Removed
* retirement can never succeed. A non-content-ID image is no longer such an
* example: plan refuses it first, before any start intent exists (HACK-1300). Retire the exact start intent and
* source only when the settled consumer's failure was never observed as a
* receipt and fresh inspection names the run `native_graph_unreserved` (an
* absent journal and no published foreground owner). Every other inspection
Expand Down
41 changes: 40 additions & 1 deletion src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ import {
} from "../constants.ts";
import { resolveGatewayConfig } from "../control-plane/extensions/gateway/config.ts";
import { listGatewayTokens } from "../control-plane/extensions/gateway/tokens.ts";
import { probeDaemonApi } from "../daemon/client.ts";
import {
probeDaemonApi,
resolveDaemonAutostartAccess,
} from "../daemon/client.ts";
import { resolveDaemonPaths } from "../daemon/paths.ts";
import { buildDaemonStatusReport, readDaemonStatus } from "../daemon/status.ts";
import { repairCaddyCaExport } from "../lib/caddy-ca-export.ts";
Expand Down Expand Up @@ -4368,12 +4371,35 @@ async function dockerInfoOk(): Promise<boolean> {
return dockerOk.exitCode === 0;
}

/**
* The `--fix` hackd repair: restart an incompatible hackd, clear stale
* pid/socket files, then start hackd. Each step runs `hack daemon ...` through
* the first `hack` on PATH, and non-interactive runs accept every step.
*
* HACK-1368: for a candidate run that `hack` is the stable CLI, so a candidate
* run, or any run with `HACK_DAEMON_AUTOSTART_DISABLE=1`, skips the whole
* repair through {@link resolveDaemonAutostartAccess}, in interactive and
* non-interactive mode alike, before it prompts or runs anything. It notes the
* skip with the reason and the command to run instead.
*/
async function maybeRepairHackd(): Promise<void> {
const report = await resolveDaemonReportForDoctorFix();
if (report.status === "running") {
return;
}

const autostart = resolveDaemonAutostartAccess();
if (!autostart.allowed) {
note(
[
`Skipped hackd repair (hackd is ${report.status}): ${autostart.message}`,
`To repair it yourself, run: ${hackdRepairCommand(report.status)}`,
].join("\n"),
"doctor fix"
);
return;
}

if (report.status === "incompatible") {
const okRestart = await doctorConfirm({
message: "Restart incompatible hackd now?",
Expand Down Expand Up @@ -4404,6 +4430,19 @@ async function maybeRepairHackd(): Promise<void> {
}
}

/** The explicit commands that perform the {@link maybeRepairHackd} steps. */
function hackdRepairCommand(
status: ReturnType<typeof buildDaemonStatusReport>["status"]
): string {
if (status === "incompatible") {
return "hack daemon restart";
}
if (status === "stale") {
return "hack daemon clear && hack daemon start";
}
return "hack daemon start";
}

/**
* Optional integrations are current when absent: a path is stale only when it
* exists and lacks the current revision marker, or exists but cannot be read.
Expand Down
69 changes: 54 additions & 15 deletions src/commands/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { resolveGatewayConfig } from "../control-plane/extensions/gateway/config
import { listGatewayTokens } from "../control-plane/extensions/gateway/tokens.ts";
import type { ControlPlaneConfig } from "../control-plane/sdk/config.ts";
import { readControlPlaneConfig } from "../control-plane/sdk/config.ts";
import { resolveDaemonAutostartAccess } from "../daemon/client.ts";
import {
getLaunchdServiceStatus,
resolveLaunchdAccess,
Expand Down Expand Up @@ -1629,7 +1630,26 @@ async function maybeOfferMacDnsRecoveryAuthorization(): Promise<void> {
}
}

async function maybeOfferMacHackdLaunchdInstall(): Promise<void> {
/**
* The macOS `hack global install` offer to run `hack daemon install`, which
* installs the per-user hackd launchd service and starts hackd through it.
* Non-interactive runs accept the offer.
*
* HACK-1368: `hack daemon install` runs through the first `hack` on PATH,
* which for a candidate run is the stable CLI, so it would install and start
* the stable hackd. A candidate run, or any run with
* `HACK_DAEMON_AUTOSTART_DISABLE=1`, skips the offer through
* {@link resolveDaemonAutostartAccess} before it reads launchd state or
* prompts. Exported for tests.
*/
export async function maybeOfferMacHackdLaunchdInstall(): Promise<void> {
const autostart = resolveDaemonAutostartAccess();
if (!autostart.allowed) {
logger.info({
message: `Skipping hackd launchd setup: ${autostart.message}`,
});
return;
}
const access = resolveLaunchdAccess();
if (!access.allowed) {
logger.info({ message: `Skipping hackd launchd setup: ${access.message}` });
Expand Down Expand Up @@ -1696,6 +1716,38 @@ async function writeWithPromptIfDifferent(
await writeTextFileIfChanged(absolutePath, content);
}

/**
* The `daemon.autoStart` step of `hack global up`: runs `hack daemon start`
* through the first `hack` on PATH and only warns when it fails.
*
* HACK-1368: for a candidate run that `hack` is the stable CLI, so a candidate
* run, or any run with `HACK_DAEMON_AUTOSTART_DISABLE=1`, skips the step
* through {@link resolveDaemonAutostartAccess}, as the implicit autostart in
* `requestDaemonJson` does. A denied run reads no config and spawns nothing.
*/
async function ensureHackdForGlobalUp(): Promise<void> {
const access = resolveDaemonAutostartAccess();
if (!access.allowed) {
logger.info({ message: `Skipping hackd start: ${access.message}` });
return;
}
const controlPlane = await readControlPlaneConfig({});
if (!controlPlane.config.daemon.autoStart) {
return;
}
logger.step({ message: "Ensuring hackd is running…" });
const invocation = await resolveHackInvocation();
const daemonExit = await run(
[invocation.bin, ...invocation.args, "daemon", "start"],
{
stdin: "ignore",
}
);
if (daemonExit !== 0) {
logger.warn({ message: "Unable to start hackd (continuing)" });
}
}

export async function globalUp(): Promise<number> {
const slimExit = failIfSlimMode({
feature: "hack global up",
Expand Down Expand Up @@ -1764,20 +1816,7 @@ export async function globalUp(): Promise<number> {
}
}

const controlPlane = await readControlPlaneConfig({});
if (controlPlane.config.daemon.autoStart) {
logger.step({ message: "Ensuring hackd is running…" });
const invocation = await resolveHackInvocation();
const daemonExit = await run(
[invocation.bin, ...invocation.args, "daemon", "start"],
{
stdin: "ignore",
}
);
if (daemonExit !== 0) {
logger.warn({ message: "Unable to start hackd (continuing)" });
}
}
await ensureHackdForGlobalUp();

logger.step({ message: "Starting Caddy…" });
const caddyExit = await run(
Expand Down
75 changes: 74 additions & 1 deletion src/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,73 @@ const DAEMON_AUTOSTART_MAX_ATTEMPTS = 2;

let lastDaemonAutostartAttemptAtMs = 0;

/**
* Opt-out: when set to `1`, CLI commands never start hackd implicitly, on any
* platform, through launchd or directly. That covers the autostart in
* {@link requestDaemonJson}, the `daemon.autoStart` step of `hack global up`,
* the macOS `hack global install` offer to run `hack daemon install`, and the
* hackd repair in `hack doctor --fix` (restart, clear and start), which it
* skips in interactive and non-interactive mode alike. An explicit
* `hack daemon start` or `hack daemon install` still works. The bundled
* `hack-v5` entrypoint sets it; `hack-next` sets only the candidate marker,
* which denies the same way.
*/
export const DAEMON_AUTOSTART_DISABLE_ENV = "HACK_DAEMON_AUTOSTART_DISABLE";

/**
* Marker that `hack-next` and the bundled `hack-v5` entrypoint set on every
* candidate run, on both backends (see `scripts/hack-v5.sh`).
*/
const CANDIDATE_ENTRYPOINT_ENV = "HACK_CANDIDATE_ENTRYPOINT";

export type DaemonAutostartBlockReason = "candidate" | "disabled";

export type DaemonAutostartAccess =
| { readonly allowed: true }
| {
readonly allowed: false;
readonly reason: DaemonAutostartBlockReason;
readonly message: string;
};

/**
* Decides whether this process may start hackd implicitly when a daemon
* request finds it unavailable.
*
* A candidate run never may (HACK-1368). Autostart starts whatever hackd the
* host resolves, not this process: the per-user launchd plist points at the
* stable binary, and the direct path runs the first `hack` on PATH. On the
* Compose backend a candidate also shares the caller's `~/.hack`, so an
* autostarted stable hackd would run beside the candidate on the same state
* root. Callers already fall back to direct Docker inspection when the daemon
* is unavailable, exactly as for a non-interactive run.
*
* {@link DAEMON_AUTOSTART_DISABLE_ENV} is checked first, then the candidate
* marker. Values are trimmed; only `1` disables, and any non-empty marker
* denies.
*/
export function resolveDaemonAutostartAccess(
opts: { readonly env?: Readonly<Record<string, string | undefined>> } = {}
): DaemonAutostartAccess {
const env = opts.env ?? process.env;
if ((env[DAEMON_AUTOSTART_DISABLE_ENV] ?? "").trim() === "1") {
return {
allowed: false,
reason: "disabled",
message: `hackd autostart is disabled by ${DAEMON_AUTOSTART_DISABLE_ENV}=1.`,
};
}
if ((env[CANDIDATE_ENTRYPOINT_ENV] ?? "").trim() !== "") {
return {
allowed: false,
reason: "candidate",
message:
"hackd autostart is disabled for a candidate run: it would start the stable hackd. Start a daemon explicitly with `hack daemon start` if you need one.",
};
}
return { allowed: true };
}

export type DaemonJsonResponse = {
readonly ok: boolean;
readonly status: number;
Expand Down Expand Up @@ -96,11 +163,17 @@ export async function requestDaemonJson(opts: {
*
* Non-interactive invocations (no TTY, HACK_NO_INTERACTIVE, --no-interactive)
* never autostart: callers fall back to direct docker inspection instead of
* blocking a scripted/agent run on daemon spawn side effects.
* blocking a scripted/agent run on daemon spawn side effects. Candidate runs
* and {@link DAEMON_AUTOSTART_DISABLE_ENV} never autostart either; see
* {@link resolveDaemonAutostartAccess}. That check runs before anything else,
* so a denied run reads no config and touches neither launchd nor PATH.
*/
async function ensureDaemonReady(opts: {
readonly paths: ReturnType<typeof resolveDaemonPaths>;
}): Promise<boolean> {
if (!resolveDaemonAutostartAccess().allowed) {
return false;
}
if (!canPrompt()) {
return false;
}
Expand Down
Loading
Loading