Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 35 additions & 16 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,27 +209,45 @@ Hack scans `.npmrc`, `.yarnrc.yml`, and `bunfig.toml` for `${VAR}` credential re
with `E_ENV_KEY_MISSING` when the selected Hack overlay cannot supply them. Values are never
printed.

Detached Compose startup has a 90-second budget by default. For a slow first
install or container launch, set the host CLI environment explicitly:
Detached Compose startup runs in two phases, each with its own budget:

| Phase | Compose invocation | Default | Variable | Range (ms) |
| --- | --- | --- | --- | --- |
| Image build/pull and container creation | `up --no-start` | 30 minutes | `HACK_COMPOSE_IMAGE_TIMEOUT_MS` | 1,000–14,400,000 |
| Start and dependency wait | `up -d --no-build` | 90 seconds | `HACK_COMPOSE_STARTUP_TIMEOUT_MS` | 1,000–3,600,000 |

A first-time build or a large image pull therefore does not consume the start
budget. Raise the budget for the phase that was hit, on the host CLI invocation
rather than in a container's environment:

```sh
# Slow first build or pull of a large image
HACK_COMPOSE_IMAGE_TIMEOUT_MS=3600000 hack up --detach
# Slow container start or a dependency that is slow to become healthy
HACK_COMPOSE_STARTUP_TIMEOUT_MS=300000 hack up --detach
HACK_COMPOSE_STARTUP_TIMEOUT_MS=300000 hack restart
```

The value must be an integer from 1,000 to 3,600,000 milliseconds; detached startup
and restart reject invalid values before project preparation or teardown. Full and service-scoped
startup/restart use the same setting. The startup phase notice and
`E_STARTUP_TIMEOUT` message report the effective budget. It covers each detached
Compose invocation, including image/container launch and dependency waiting, not
lifecycle hooks or an end-to-end application readiness deadline. A restart repair
attempt has its own invocation budget and is reported separately. Foreground
`up` stays unbounded; `down` retains its separate 90-second limit.

On timeout, Hack terminates the owned Compose process group. Containers may still
be running: inspect `hack ps` and `hack logs` before retrying. Raising the budget
does not fix a failed installer or prove readiness. Set this on the host invocation,
not merely in a container's environment.
Detached startup and restart reject invalid values before project preparation or
teardown. Full and service-scoped startup/restart use the same settings. The
startup notice reports both effective budgets. On expiry, `E_STARTUP_TIMEOUT`
names the phase that was hit, its budget, and the variable that raises it; with
`--json`, `error.detail` carries `composePhase` (`images` or `start`),
`budgetMs`, and `budgetEnv`. The start budget covers container start and Compose
`depends_on` waiting, not lifecycle hooks or an end-to-end application readiness
deadline. A restart repair attempt has its own budgets and is reported
separately. Foreground `up` stays a single unbounded Compose invocation; `down`
retains its separate 90-second limit.

On timeout, Hack terminates the owned Compose process group of that phase. After an
image-phase timeout no container was started by that attempt, but containers may
have been created or recreated. After a start-phase timeout containers may still be
running. Inspect `hack ps` and `hack logs` before retrying. Raising a budget does not
fix a failed installer or prove readiness.

Services with `pull_policy: always` still pull again in the start phase because
Compose applies that policy on every `up`; such pulls count against the start
budget.

To share dependency data only across worktrees with the same lockfile/runtime fingerprint, label
the bootstrap service with a logical top-level Compose volume:
Expand Down Expand Up @@ -292,7 +310,8 @@ be mistaken for a ready one. No project lifecycle hook or unrelated dependency i
A failed installer leaves existing consumer containers untouched; JSON lifecycle commands return
`E_DEPENDENCY_BOOTSTRAP_FAILED`. Each initializer in detached scoped `up` and targeted `restart` uses the configured
`HACK_COMPOSE_STARTUP_TIMEOUT_MS` budget and reports `E_STARTUP_TIMEOUT` on expiry.
The subsequent Compose launch has its own equal budget. Consumer `run` and foreground
That budget also covers any installer image pull or build, because `run` acquires
the image itself. The subsequent Compose launch has its own phased budgets. Consumer `run` and foreground
scoped `up` retain a separate ten-minute initializer deadline.

## Branch instances and linked worktrees
Expand Down
65 changes: 54 additions & 11 deletions src/backends/runtime-backend.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
type ComposeStartupPhase,
DEFAULT_COMPOSE_STARTUP_TIMEOUT_MS,
resolveComposeImageTimeoutMs,
resolveComposeStartupTimeoutMs,
} from "../lib/compose-startup-budget.ts";
import { withDependencyStartupProgress } from "../lib/dependency-startup-progress.ts";
Expand Down Expand Up @@ -39,7 +41,15 @@ export interface RuntimeUpOptions extends RuntimeBaseOptions {
readonly services: readonly string[];
};
readonly detach: boolean;
/** Detached `start` phase budget (container start and dependency wait). */
readonly startupTimeoutMs?: number;
/** Detached `images` phase budget (image build/pull and container creation). */
readonly imageTimeoutMs?: number;
/**
* Called before each detached phase begins, so a caller can attribute a
* non-zero exit (including the 124 deadline) to the phase that produced it.
*/
readonly onStartupPhase?: (phase: ComposeStartupPhase) => void;
readonly services?: readonly string[];
readonly noDeps?: boolean;
readonly forceRecreate?: boolean;
Expand Down Expand Up @@ -85,26 +95,52 @@ function buildComposeArgs(opts: RuntimeBaseOptions): string[] {
export const composeRuntimeBackend: RuntimeBackend = {
name: "compose",
async up(opts) {
const cmd = [
...buildComposeArgs(opts),
"up",
...(opts.detach ? ["-d"] : []),
...(opts.noDeps ? ["--no-deps"] : []),
...(opts.forceRecreate ? ["--force-recreate"] : []),
...(opts.services ?? []),
];
if (opts.detach) {
const budgetEnv = { ...process.env, ...opts.env };
const startupTimeoutMs = resolveComposeStartupTimeoutMs({
startupTimeoutMs: opts.startupTimeoutMs,
env: { ...process.env, ...opts.env },
env: budgetEnv,
});
const launch = () =>
const imageTimeoutMs = resolveComposeImageTimeoutMs({
imageTimeoutMs: opts.imageTimeoutMs,
env: budgetEnv,
});
const scope = [
...(opts.noDeps ? ["--no-deps"] : []),
...(opts.services ?? []),
];
const runPhase = (cmd: readonly string[], timeoutMs: number) =>
run(cmd, {
cwd: opts.cwd,
env: opts.env,
stdout: opts.routeStdoutToStderr ? "stderr" : "inherit",
timeoutMs: startupTimeoutMs,
timeoutMs,
});
// Image build/pull can take far longer than starting containers, so it
// runs (with container creation/recreation) under its own budget. The
// start invocation then only starts the prepared containers and waits on
// Compose dependency conditions; --no-build keeps it from rebuilding.
const launch = async () => {
opts.onStartupPhase?.("images");
const prepared = await runPhase(
[
...buildComposeArgs(opts),
"up",
"--no-start",
...(opts.forceRecreate ? ["--force-recreate"] : []),
...scope,
],
imageTimeoutMs
);
if (prepared !== 0) {
return prepared;
}
opts.onStartupPhase?.("start");
return await runPhase(
[...buildComposeArgs(opts), "up", "-d", "--no-build", ...scope],
startupTimeoutMs
);
};
const progress = opts.dependencyProgress;
if (!progress) {
return await launch();
Expand All @@ -129,6 +165,13 @@ export const composeRuntimeBackend: RuntimeBackend = {
});
}

const cmd = [
...buildComposeArgs(opts),
"up",
...(opts.noDeps ? ["--no-deps"] : []),
...(opts.forceRecreate ? ["--force-recreate"] : []),
...(opts.services ?? []),
];
const env = mergeSpawnEnv(opts.env);
const proc = Bun.spawn(cmd, {
cwd: opts.cwd,
Expand Down
Loading
Loading