From f0856b52d8ffb5b650dea78c6d327d94d305c6c3 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 18:05:24 +0000 Subject: [PATCH 1/4] feat(lifecycle): stream --progress ndjson from up, down and restart `hack up|down|restart --json --progress` now writes an ndjson stream on the Compose backend: a start line carrying the hack.lifecycle-progress.v1 schema, progress lines {type, ts, service, phase}, and the ordinary --json envelope as one compact closing line. Phases come only from signals the Compose path already observes: the detached startup phase callback (images, starting), down/restart stop (stopping, stopped), dependency cache markers, and the envelope's final per-service states. --progress requires --json. The native backend and native-authored projects refuse it before any work. Refusals before the stream starts keep the single pretty envelope. Documented in docs/reference/lifecycle-progress.md (HACK-2218 R6). Co-Authored-By: Claude Opus 5.5 --- apps/desktop/DESIGN.md | 2 +- docs/reference/cli.md | 3 + docs/reference/lifecycle-progress.md | 108 ++++++++++++ docs/versions/v5/core.md | 4 +- src/backends/runtime-backend.ts | 2 + src/commands/project.ts | 64 ++++++- src/lib/cli-result.ts | 7 +- src/lib/lifecycle-progress.ts | 239 +++++++++++++++++++++++++++ tests/lifecycle-progress-cli.test.ts | 214 ++++++++++++++++++++++++ tests/lifecycle-progress.test.ts | 162 ++++++++++++++++++ 10 files changed, 796 insertions(+), 9 deletions(-) create mode 100644 docs/reference/lifecycle-progress.md create mode 100644 src/lib/lifecycle-progress.ts create mode 100644 tests/lifecycle-progress-cli.test.ts create mode 100644 tests/lifecycle-progress.test.ts diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 11c6e2336..409cb981a 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -378,7 +378,7 @@ Each is small enough for one PR. 5. **R4** Logs: document the `--json` ndjson as stable; `--services a,b` honoured by every backend; `--max-line-bytes ` setting `entry.truncated`. 6. **R5a** Activity writer: append-only `activity.jsonl` in the Hack home, one line per `up`/`down`/`restart`/autostart (`ts, project, branch, action, origin, outcome, failed_services, duration_ms`), `O_APPEND` writes under one line size limit, rotation at a size cap, `origin` from `HACK_ORIGIN` (the app sets `app`). 7. **R5b** Activity reader: `hack activity --json [--project] [--limit]`. -8. **R6** Action progress: `hack up|down|restart --json --progress` emitting ndjson `{type:"progress", service, phase}` before the final envelope. +8. **R6** Action progress: `hack up|down|restart --json --progress` emitting ndjson `{type:"progress", service, phase}` before the final envelope. The Compose slice exists; see [Lifecycle progress stream](../../docs/reference/lifecycle-progress.md). Native backend support is open. 9. **R7a — exists.** Doctor audit schema 1 adds per-check `area`, `scope: machine | project` and `fix: {kind: safe | admin | manual, args[], doc_url}` inside the existing `{ok,data}` envelope. Unknown checks default to Other/manual with no executable args. A category is guidance, not permission to run it. 10. **R7b** One fix without prompts: `hack doctor --fix --only --yes --json`, only for `kind: safe`. 11. **R7c** Machine-only doctor: `hack doctor --machine --json` (no project), so the app does not need to de-duplicate. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index af5b832cf..38f856b1e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -187,6 +187,7 @@ hack up [services...] [options] | `--profile ` | Enable one or more compose profiles (comma-separated) | | `--target ` | Execution target routing (auto routes to remote when project execution mode requires it) | | `--json` | Output JSON (machine-readable) | +| `--progress` | With --json: stream ndjson progress lines before the result envelope (Compose backend) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | @@ -216,6 +217,7 @@ hack down [options] | `--remove-data` | Native backend: delete a stopped project's owned data volumes so the next up starts fresh (asks first) | | `--yes` | Confirm --prune-caches or --remove-data without prompting | | `--json` | Output JSON (machine-readable) | +| `--progress` | With --json: stream ndjson progress lines before the result envelope (Compose backend) | | `--recover` | Explicitly reconcile an interrupted native stop, or stop the services of a native run whose foreground owner exited, while retaining data | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | @@ -249,6 +251,7 @@ hack restart [services...] [options] | `--profile ` | Enable one or more compose profiles (comma-separated) | | `--target ` | Execution target routing (auto routes to remote when project execution mode requires it) | | `--json` | Output JSON (machine-readable) | +| `--progress` | With --json: stream ndjson progress lines before the result envelope (Compose backend) | | `--recover-frontend` | Recover an interrupted native frontend after proving its owner and effects are gone | | `--expect-finalization-attempt <32-hex>` | Require this exact interrupted native frontend attempt | | `--expect-frontend-pid ` | Previously observed frontend PID (required only for legacy v1 state) | diff --git a/docs/reference/lifecycle-progress.md b/docs/reference/lifecycle-progress.md new file mode 100644 index 000000000..f8dcdb4a8 --- /dev/null +++ b/docs/reference/lifecycle-progress.md @@ -0,0 +1,108 @@ +# Lifecycle progress stream + +`hack up --json --progress`, `hack down --json --progress` and +`hack restart --json --progress` print a newline-delimited JSON (ndjson) stream on +stdout instead of one pretty-printed envelope. Callers that show progress, such as +the desktop app, read it line by line. Stderr is unchanged and is not part of the +contract. + +This is the Compose backend only. The native backend does not support it yet (see +[Refusals](#refusals)). + +## The stream + +Every line is one JSON object. + +1. **Start line**, written once Hack validated the options and resolved the + project: + + ```json + {"schema":"hack.lifecycle-progress.v1","type":"start","ts":"2026-11-14T17:00:00.000Z","action":"up","project":"shop"} + ``` + + `action` is `up`, `down` or `restart`. `project` is the Compose project name + (`` or `--`). `schema` appears only on this line. + +2. **Progress lines**, zero or more: + + ```json + {"type":"progress","ts":"2026-11-14T17:00:01.000Z","service":null,"phase":"images"} + {"type":"progress","ts":"2026-11-14T17:00:09.000Z","service":"api","phase":"running"} + ``` + + `service` is `null` for a project-level phase and a Compose service name + otherwise. `ts` is an ISO 8601 UTC timestamp. + +3. **Closing line**: the same envelope the command prints with `--json` alone, + written as one compact line. Nothing follows it. + + ```json + {"ok":true,"data":{"action":"up","project":"shop","branch":null,"composeProject":"shop","services":{"started":["api"],"completed":["migrate"],"stopped":[],"failed":[]},"durationMs":9120}} + ``` + + A failure after the start line also ends with exactly one envelope line, + `{"ok":false,"error":{"code":...,"message":...}}`. Exit codes are the same as + with `--json` alone. + +```mermaid +stateDiagram-v2 + [*] --> Refused: usage error or project not found + Refused --> [*]: one pretty envelope, no stream + [*] --> Started: start line (schema) + Started --> Progress: progress line + Progress --> Progress: progress line + Started --> Closed: envelope line + Progress --> Closed: envelope line + Closed --> [*] +``` + +## Phases + +Hack reports only signals the Compose path already observes. It does not parse +Compose's terminal output or poll for extra state. A phase that did not happen is +skipped, so a consumer must not wait for any particular phase. + +| Phase | `service` | Source | +|---|---|---| +| `images` | `null` | Compose image preparation (`up --no-start`) is starting. | +| `starting` | `null` | Image preparation succeeded; containers are being started (`up -d`). | +| `stopping` | `null` | `down` is about to stop the containers; `restart` is about to run its stop phase (down hooks and host processes). | +| `stopped` | `null` | That stop finished successfully. | +| `waiting`, `installing`, `verifying`, `ready`, `failed` | service | Dependency cache startup markers, also printed to stderr as `Dependency cache : `. Only services that use the dependency cache report them. | +| `running`, `completed`, `stopped`, `failed` | service | The final state of each service, taken from the closing envelope and written just before it. | + +What each command reports: + +- `up`: `images`, then `starting` if image preparation succeeded, any dependency + cache phases, then the final states. `up --json` always runs detached, so the + `images` and `starting` signals always fire; a failure during image preparation + stops after `images`. +- `down`: `stopping`, `stopped` when `docker compose down` succeeded, then one + `stopped` line per service that was running before. +- `restart`: `stopping` and `stopped` for the stop phase, then the same phases as + `up`. A restart of named services (`hack restart api`) skips the stop phase. + When a full restart's start fails and Hack repairs it with a second Compose + start, that repair reports no `images` or `starting` lines. + +Final per-service lines come from the envelope's `data.services` lists on success +(`started` maps to `running`). On an `E_STARTUP_INCOMPLETE` error they come from +`error.detail.running`, `completed` and `failed`. Other errors have no final +per-service lines. + +A remote-routed action writes the start line and then its envelope. + +## Refusals + +These end with the ordinary pretty `--json` envelope (or usage text without +`--json`) and no stream, because they happen before the start line: + +- `--progress` without `--json`: usage error `--progress requires --json.` +- The native backend (`HACK_RUNTIME_BACKEND=native`): usage error + `Native lifecycle commands do not support --progress yet.` before any work. +- A native-authored project refuses `--progress` as an unsupported option of its + native Compose path. +- Other usage errors, a missing project, Docker missing from `PATH`, and an + invalid shared helper configuration. + +A consumer that needs to tell the two apart can check whether the first line +parses as a complete JSON object with `schema`. diff --git a/docs/versions/v5/core.md b/docs/versions/v5/core.md index 60dc26592..b72ad4f5f 100644 --- a/docs/versions/v5/core.md +++ b/docs/versions/v5/core.md @@ -65,7 +65,9 @@ and [Worktrees and watchers](../guides/development-runtime-safety.md). ## Use Hack from an agent or script Agents use the same CLI. `--json` returns structured results with stable error codes -and `--no-interactive` prevents a command from waiting for input. No plugin, server +and `--no-interactive` prevents a command from waiting for input. On the Compose +backend, `hack up|down|restart --json --progress` streams ndjson progress lines +before the final envelope. No plugin, server or account is required. [Agent handoff](agent-handoff.md) and [Agent integrations](../integrations.md) cover the optional extras. diff --git a/src/backends/runtime-backend.ts b/src/backends/runtime-backend.ts index 9243c17ff..8128cc0ef 100644 --- a/src/backends/runtime-backend.ts +++ b/src/backends/runtime-backend.ts @@ -6,6 +6,7 @@ import { resolveComposeStartupTimeoutMs, } from "../lib/compose-startup-budget.ts"; import { withDependencyStartupProgress } from "../lib/dependency-startup-progress.ts"; +import { reportLifecycleProgress } from "../lib/lifecycle-progress.ts"; import { exec, run } from "../lib/shell.ts"; import { readLinesFromStream } from "../ui/lines.ts"; import { createStructuredLogGrouper } from "../ui/log-group.ts"; @@ -214,6 +215,7 @@ export const composeRuntimeBackend: RuntimeBackend = { env: opts.env, onPhase: (service, phase) => { process.stderr.write(`Dependency cache ${service}: ${phase}\n`); + reportLifecycleProgress({ service, phase }); }, onUnavailable: () => { process.stderr.write( diff --git a/src/commands/project.ts b/src/commands/project.ts index c2569eb2e..8db2bfa9c 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -262,6 +262,12 @@ import { canPrompt, requireInteractive } from "../lib/interactivity.ts"; import { resolveInternalExtraHosts } from "../lib/internal-extra-hosts.ts"; import { parseJsonLines } from "../lib/json-lines.ts"; import { runLifecycleCommandChild } from "../lib/lifecycle-command-child.ts"; +import { + beginLifecycleProgress, + closeLifecycleProgress, + prepareLifecycleProgress, + reportLifecycleProgress, +} from "../lib/lifecycle-progress.ts"; import { createLifecycleLogSink, drainLifecycleOutput, @@ -563,6 +569,13 @@ const initOptions = [ optNoDiscovery, optWith, ] as const; +const optProgress = defineOption({ + name: "progress", + type: "boolean", + long: "--progress", + description: + "With --json: stream ndjson progress lines before the result envelope (Compose backend)", +} as const); const upOptions = [ optPath, optProject, @@ -572,6 +585,7 @@ const upOptions = [ optProfile, optTarget, optJson, + optProgress, ] as const; const downOptions = [ optPath, @@ -590,6 +604,7 @@ const downOptions = [ } as const), optYes, optJson, + optProgress, defineOption({ name: "recoverNativeCompose", type: "boolean", @@ -606,6 +621,7 @@ const restartOptions = [ optProfile, optTarget, optJson, + optProgress, optRecoverFrontend, optExpectFinalizationAttempt, optExpectFrontendPid, @@ -1121,6 +1137,10 @@ function trackComposeStartupPhase(): { return { onStartupPhase: (phase) => { current = phase; + reportLifecycleProgress({ + service: null, + phase: phase === "images" ? "images" : "starting", + }); if (phase === "start") { startedAt = performance.now(); } @@ -1155,12 +1175,16 @@ function composeStartupIncompleteMessage( /** * Emit the `--json` envelope for a lifecycle command and map it to the - * standard 0/1 exit semantics. + * standard 0/1 exit semantics. A started `--progress` stream closes with it + * as one compact line; otherwise it is the ordinary pretty envelope. */ function emitLifecycleResult(opts: { readonly result: CliResult; readonly exitCode: number; }): number { + if (closeLifecycleProgress({ result: opts.result })) { + return opts.exitCode; + } emitCliResult({ result: opts.result }); return opts.exitCode; } @@ -1193,6 +1217,10 @@ function emitRemoteLifecycleJson(opts: { readonly branch: string | null; readonly startedAtMs?: number; }): number { + const composeProject = opts.branch + ? `${opts.project}--${opts.branch}` + : opts.project; + beginLifecycleProgress({ project: composeProject }); if (opts.exitCode !== 0) { return emitLifecycleResult({ result: errorResult({ @@ -1209,9 +1237,7 @@ function emitRemoteLifecycleJson(opts: { action: opts.action, project: opts.project, branch: opts.branch, - composeProject: opts.branch - ? `${opts.project}--${opts.branch}` - : opts.project, + composeProject, durationMs: Date.now() - (opts.startedAtMs ?? Date.now()), }), }), @@ -6356,6 +6382,7 @@ async function restartNativeImageProject({ removeData: false, yes: false, recoverNativeCompose: false, + progress: false, }, positionals: {}, raw: args.raw, @@ -6395,6 +6422,12 @@ async function handleUp({ readonly ctx: CliContext; readonly args: UpArgs; }): Promise { + prepareLifecycleProgress({ + action: "up", + progress: args.options.progress, + json: args.options.json, + backend: process.env.HACK_RUNTIME_BACKEND, + }); // Refuse a malformed --target before any project or runtime work. parseRequestedTarget({ raw: args.options.target }); if (resolveNativeRuntimeSelection()) { @@ -6472,7 +6505,7 @@ async function handleUp({ json: args.options.json, detach: args.options.detach, services: args.positionals.services, - unsupportedOptions: Boolean(args.options.target), + unsupportedOptions: Boolean(args.options.target || args.options.progress), }); if (authored !== null) { return authored; @@ -6604,6 +6637,7 @@ async function runUpCommand({ projectName, branch, }); + beginLifecycleProgress({ project: composeProjectName ?? baseProjectName }); const devHost = await resolveRuntimeDevHost({ project, branch }); const aliasHost = resolveBranchAliasHost({ devHost, cfg }); const internalSettings = resolveInternalSettings(cfg); @@ -7145,6 +7179,12 @@ async function handleDown({ /** The native runtime requests of this down; a restart starts them in their own process group. */ readonly invoke?: typeof invokeNativeRuntime; }): Promise { + prepareLifecycleProgress({ + action: "down", + progress: args.options.progress, + json: args.options.json, + backend: process.env.HACK_RUNTIME_BACKEND, + }); // Refuse a malformed --target before any project or runtime work. parseRequestedTarget({ raw: args.options.target }); const authored = await tryNativeComposeCommand({ @@ -7165,6 +7205,7 @@ async function handleDown({ args.options.pruneCaches, args.options.removeData, args.options.yes, + args.options.progress, ].some(Boolean), }); if (authored !== null) { @@ -7443,6 +7484,7 @@ async function runDownCommand({ projectName, branch, }); + beginLifecycleProgress({ project: composeProjectName ?? baseProjectName }); const effectiveEnvName = await resolveStoredRuntimeEnvName({ requestedEnvName: envName, projectDir: project.projectDir, @@ -7506,6 +7548,7 @@ async function runDownCommand({ cfg, composeProject: lifecycleComposeProject, }); + reportLifecycleProgress({ service: null, phase: "stopping" }); code = await composeRuntimeBackend.down({ composeFiles: [project.composeFile], composeProject: composeProjectName, @@ -7538,6 +7581,7 @@ async function runDownCommand({ } return code; } + reportLifecycleProgress({ service: null, phase: "stopped" }); if (helperReleaseFailures.length > 0) { const message = sharedHelperReleaseFailureMessage(helperReleaseFailures); if (json) { @@ -8394,6 +8438,12 @@ async function handleRestart({ const expectAttempt = args.options.expectFinalizationAttempt; const legacyPid = args.options.expectFrontendPid; validateFrontendRecoveryOptions({ recover, expectAttempt, legacyPid }); + prepareLifecycleProgress({ + action: "restart", + progress: args.options.progress, + json: args.options.json, + backend: process.env.HACK_RUNTIME_BACKEND, + }); // Refuse a malformed --target before any project or runtime work. parseRequestedTarget({ raw: args.options.target }); const authored = await tryNativeComposeCommand({ @@ -8411,6 +8461,7 @@ async function handleRestart({ recover, expectAttempt, legacyPid, + args.options.progress, ].some(Boolean), }); if (authored !== null) { @@ -8530,6 +8581,7 @@ async function runRestartCommand({ baseProjectName, branch, }); + beginLifecycleProgress({ project: composeProjectName ?? baseProjectName }); const projectName = sanitizeProjectSlug(baseProjectName); const allServiceNames = await readComposeServiceNames(project.composeFile); const requestedServices = resolveRequestedComposeServices({ @@ -8606,6 +8658,7 @@ async function runRestartCommand({ envByService: preflightEnv.preflightEnvByService, }); + reportLifecycleProgress({ service: null, phase: "stopping" }); const downPhase = await runRestartDownPhase({ project, cfg, @@ -8652,6 +8705,7 @@ async function runRestartCommand({ return downCode; } + reportLifecycleProgress({ service: null, phase: "stopped" }); await removeProjectRuntimeStateEntry({ projectDir: project.projectDir, composeProject: lifecycleComposeProject, diff --git a/src/lib/cli-result.ts b/src/lib/cli-result.ts index 099e20fb7..4c7dc728f 100644 --- a/src/lib/cli-result.ts +++ b/src/lib/cli-result.ts @@ -98,12 +98,15 @@ export function errorResult(opts: { } /** - * Serialize an envelope for stdout (pretty-printed, trailing newline). + * Serialize an envelope for stdout with a trailing newline: pretty-printed + * by default, or as one line with `compact` (the closing line of an ndjson + * stream such as `--progress`). */ export function renderCliResult(opts: { readonly result: CliResult; + readonly compact?: boolean; }): string { - return `${JSON.stringify(opts.result, null, 2)}\n`; + return `${JSON.stringify(opts.result, null, opts.compact ? undefined : 2)}\n`; } /** diff --git a/src/lib/lifecycle-progress.ts b/src/lib/lifecycle-progress.ts new file mode 100644 index 000000000..2ffc01780 --- /dev/null +++ b/src/lib/lifecycle-progress.ts @@ -0,0 +1,239 @@ +/** + * `hack up|down|restart --json --progress`: an ndjson stream on stdout. + * + * Contract (schema {@link LIFECYCLE_PROGRESS_SCHEMA}): + * - line 1: `{schema, type: "start", ts, action, project?}`, written once the + * command resolved its project; + * - zero or more `{type: "progress", ts, service, phase}` lines, where + * `service` is `null` for project-level phases; + * - one closing line: the command's ordinary `--json` envelope, compact. + * Nothing follows it. + * + * A command arms the stream before any work and starts it once validation + * and project resolution succeeded. A result emitted before the stream + * started (a refusal) is the ordinary pretty envelope, not a stream. + * + * Phases are only signals the Compose path already observes; a phase that + * did not happen is skipped, never synthesized. + */ + +import { CliUsageError } from "../cli/command.ts"; +import { type CliResult, renderCliResult } from "./cli-result.ts"; +import type { DependencyStartupPhase } from "./dependency-startup-progress.ts"; +import { isRecord } from "./guards.ts"; + +export const LIFECYCLE_PROGRESS_SCHEMA = "hack.lifecycle-progress.v1"; + +export type LifecycleProgressAction = "up" | "down" | "restart"; + +/** Project-level phases (`service: null`). */ +export type LifecycleProjectPhase = + | "images" + | "starting" + | "stopping" + | "stopped"; + +/** Final per-service state, taken from the closing envelope. */ +export type LifecycleServiceFinalPhase = + | "running" + | "completed" + | "stopped" + | "failed"; + +export type LifecycleProgressPhase = + | LifecycleProjectPhase + | DependencyStartupPhase + | LifecycleServiceFinalPhase; + +export type LifecycleProgressEvent = { + readonly service: string | null; + readonly phase: LifecycleProgressPhase; +}; + +/** + * One command's stream. `write` receives complete lines (with a trailing + * newline); `now` supplies the timestamp, injectable for tests. + */ +export class LifecycleProgressStream { + private state: "armed" | "streaming" | "closed" = "armed"; + private readonly action: LifecycleProgressAction; + private readonly write: (line: string) => void; + private readonly now: () => Date; + + constructor(opts: { + readonly action: LifecycleProgressAction; + readonly write: (line: string) => void; + readonly now?: () => Date; + }) { + this.action = opts.action; + this.write = opts.write; + this.now = opts.now ?? (() => new Date()); + } + + /** Write the first line. Later calls are ignored. */ + begin(opts: { readonly project?: string }): void { + if (this.state !== "armed") { + return; + } + this.state = "streaming"; + this.line({ + schema: LIFECYCLE_PROGRESS_SCHEMA, + type: "start", + ts: this.ts(), + action: this.action, + ...(opts.project === undefined ? {} : { project: opts.project }), + }); + } + + /** Write one progress line; ignored before {@link begin} or after close. */ + progress(event: LifecycleProgressEvent): void { + if (this.state !== "streaming") { + return; + } + this.line({ + type: "progress", + ts: this.ts(), + service: event.service, + phase: event.phase, + }); + } + + /** + * Write the final per-service lines and the compact envelope, then close. + * Returns false (writing nothing) when the stream never began, so the + * caller prints the ordinary pretty envelope. + */ + close(opts: { readonly result: CliResult }): boolean { + if (this.state !== "streaming") { + return false; + } + for (const event of finalServiceEvents(opts.result)) { + this.progress(event); + } + this.state = "closed"; + this.write(renderCliResult({ result: opts.result, compact: true })); + return true; + } + + private ts(): string { + return this.now().toISOString(); + } + + private line(value: Record): void { + this.write(`${JSON.stringify(value)}\n`); + } +} + +const FINAL_SERVICE_KEYS: ReadonlyArray< + readonly [string, LifecycleServiceFinalPhase] +> = [ + ["started", "running"], + ["running", "running"], + ["completed", "completed"], + ["stopped", "stopped"], + ["failed", "failed"], +]; + +/** + * Per-service final states the envelope already carries: `data.services` + * on success, or the `running/completed/failed` lists an incomplete-startup + * error puts in `detail`. Other envelopes yield none. + */ +export function finalServiceEvents( + result: CliResult +): readonly LifecycleProgressEvent[] { + let source: unknown = result.ok ? undefined : result.error.detail; + if (result.ok && isRecord(result.data)) { + source = result.data.services; + } + if (!isRecord(source)) { + return []; + } + const events: LifecycleProgressEvent[] = []; + for (const [key, phase] of FINAL_SERVICE_KEYS) { + const names = source[key]; + if (!Array.isArray(names)) { + continue; + } + for (const name of names) { + if (typeof name === "string") { + events.push({ service: name, phase }); + } + } + } + return events; +} + +let active: LifecycleProgressStream | null = null; + +/** + * Validate `--progress` before any project or runtime work and arm the + * stream when requested. It needs `--json`, and only the Compose backend + * reports phases today: the native backend (`HACK_RUNTIME_BACKEND=native`) + * refuses it. Native-authored projects refuse it through their + * unsupported-options check. + */ +export function prepareLifecycleProgress(opts: { + readonly action: LifecycleProgressAction; + readonly progress: boolean | undefined; + readonly json: boolean | undefined; + readonly backend: string | undefined; +}): void { + if (opts.progress !== true) { + return; + } + if (opts.json !== true) { + throw new CliUsageError("--progress requires --json."); + } + if (opts.backend === "native") { + throw new CliUsageError( + "Native lifecycle commands do not support --progress yet." + ); + } + armLifecycleProgress({ action: opts.action }); +} + +/** + * Arm the process-wide stream for this command. One lifecycle command runs + * per process; deep Compose code reports through + * {@link reportLifecycleProgress} without threading a parameter. + */ +export function armLifecycleProgress(opts: { + readonly action: LifecycleProgressAction; + readonly write?: (line: string) => void; + readonly now?: () => Date; +}): LifecycleProgressStream { + active = new LifecycleProgressStream({ + action: opts.action, + write: opts.write ?? ((line) => process.stdout.write(line)), + now: opts.now, + }); + return active; +} + +/** Start the armed stream, if any. */ +export function beginLifecycleProgress(opts: { + readonly project?: string; +}): void { + active?.begin(opts); +} + +/** Report one phase; a no-op unless a stream is armed and started. */ +export function reportLifecycleProgress(event: LifecycleProgressEvent): void { + active?.progress(event); +} + +/** + * Close the active stream with the command's envelope. Returns false when no + * stream started; the caller then prints the ordinary envelope. + */ +export function closeLifecycleProgress(opts: { + readonly result: CliResult; +}): boolean { + return active?.close(opts) ?? false; +} + +/** Test seam: drop the process-wide stream. */ +export function resetLifecycleProgress(): void { + active = null; +} diff --git a/tests/lifecycle-progress-cli.test.ts b/tests/lifecycle-progress-cli.test.ts new file mode 100644 index 000000000..6b45225a8 --- /dev/null +++ b/tests/lifecycle-progress-cli.test.ts @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { LIFECYCLE_PROGRESS_SCHEMA } from "../src/lib/lifecycle-progress.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +const CLI = resolve(import.meta.dir, "..", "index.ts"); + +/** + * Stub `docker`: Compose `up` succeeds unless STUB_UP_FAIL=1, `ps --format` + * reports `api` running and `migrate` completed, everything else succeeds. + */ +const STUB = `#!/bin/sh +if [ "$1" != "compose" ]; then exit 0; fi +case " $* " in + *" up "*) + if [ "\${STUB_UP_FAIL:-}" = "1" ]; then exit 3; fi + exit 0 ;; + *" ps "*) + case " $* " in + *" --format "*) + printf '%s\\n' '{"ID":"c1","Service":"api","Name":"shop-api-1","State":"running","Status":"Up","ExitCode":0}' + printf '%s\\n' '{"ID":"c2","Service":"migrate","Name":"shop-migrate-1","State":"exited","Status":"Exited (0)","ExitCode":0}' ;; + esac + exit 0 ;; + *) exit 0 ;; +esac +`; + +let root = ""; +let project = ""; +let bin = ""; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-lifecycle-progress-")); + project = join(root, "project"); + bin = join(root, "bin"); + const hack = join(project, ".hack"); + await mkdir(hack, { recursive: true }); + await mkdir(bin); + await writeFile(join(bin, "docker"), STUB); + await chmod(join(bin, "docker"), 0o755); + await writeFile( + join(hack, "hack.config.json"), + JSON.stringify({ name: "shop", internal: { dns: false, tls: false } }) + ); + await writeFile( + join(hack, "docker-compose.yml"), + "services:\n api:\n image: alpine:3.22\n migrate:\n image: alpine:3.22\n restart: 'no'\n" + ); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +async function cli( + args: readonly string[], + extra: Record = {} +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const child = Bun.spawn([process.execPath, CLI, ...args], { + cwd: project, + env: privateCliEnv({ + root, + env: { + PATH: `${bin}:/usr/bin:/bin`, + HACK_RUNTIME_BACKEND: "compose", + HACK_NO_INTERACTIVE: "1", + NO_COLOR: "1", + ...extra, + }, + }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + return { exitCode, stdout, stderr }; +} + +/** Every stdout line must be one JSON object; returns them parsed. */ +function ndjson(stdout: string): Record[] { + expect(stdout.endsWith("\n")).toBe(true); + return stdout + .slice(0, -1) + .split("\n") + .map((line) => JSON.parse(line) as Record); +} + +function expectStreamShape( + lines: readonly Record[], + action: string +): void { + expect(lines[0]).toMatchObject({ + schema: LIFECYCLE_PROGRESS_SCHEMA, + type: "start", + action, + project: "shop", + }); + expect(typeof lines[0]?.ts).toBe("string"); + for (const line of lines.slice(1, -1)) { + expect(line.type).toBe("progress"); + expect(line).not.toHaveProperty("schema"); + expect(typeof line.ts).toBe("string"); + } + expect(lines.at(-1)).toHaveProperty("ok"); +} + +test("up --json --progress streams start, progress lines and one compact envelope", async () => { + const result = await cli(["up", "--json", "--progress"]); + expect(result.exitCode).toBe(0); + const lines = ndjson(result.stdout); + expectStreamShape(lines, "up"); + const progress = lines.slice(1, -1).map((line) => [line.service, line.phase]); + expect(progress).toEqual([ + [null, "images"], + [null, "starting"], + ["api", "running"], + ["migrate", "completed"], + ]); + const envelope = lines.at(-1); + expect(envelope).toMatchObject({ + ok: true, + data: { action: "up", services: { started: ["api"] } }, + }); + // The envelope is one compact line and nothing follows it. + const rawLines = result.stdout.trimEnd().split("\n"); + expect(rawLines.at(-1)?.startsWith('{"ok":true,')).toBe(true); +}, 60_000); + +test("a failing Compose up ends the stream with one compact error envelope", async () => { + const result = await cli(["up", "--json", "--progress"], { + STUB_UP_FAIL: "1", + }); + expect(result.exitCode).not.toBe(0); + const lines = ndjson(result.stdout); + expectStreamShape(lines, "up"); + expect(lines.slice(1, -1).map((line) => line.phase)).toEqual(["images"]); + expect(lines.at(-1)).toMatchObject({ + ok: false, + error: { code: "E_COMPOSE_FAILED" }, + }); + expect( + result.stdout.trimEnd().split("\n").at(-1)?.startsWith('{"ok":false,') + ).toBe(true); +}, 60_000); + +test("down --json --progress reports stopping and stopped", async () => { + const result = await cli(["down", "--json", "--progress"]); + expect(result.exitCode).toBe(0); + const lines = ndjson(result.stdout); + expectStreamShape(lines, "down"); + expect(lines.slice(1, -1).map((line) => [line.service, line.phase])).toEqual([ + [null, "stopping"], + [null, "stopped"], + ["api", "stopped"], + ]); + expect(lines.at(-1)).toMatchObject({ ok: true, data: { action: "down" } }); +}, 60_000); + +test("restart --json --progress reports its stop and start phases", async () => { + const result = await cli(["restart", "--json", "--progress"]); + expect(result.exitCode).toBe(0); + const lines = ndjson(result.stdout); + expectStreamShape(lines, "restart"); + expect(lines.slice(1, -1).map((line) => [line.service, line.phase])).toEqual([ + [null, "stopping"], + [null, "stopped"], + [null, "images"], + [null, "starting"], + ["api", "running"], + ["migrate", "completed"], + ]); + expect(lines.at(-1)).toMatchObject({ + ok: true, + data: { action: "restart" }, + }); +}, 60_000); + +test("--progress without --json is a usage error", async () => { + for (const action of ["up", "down", "restart"]) { + const result = await cli([action, "--progress"]); + expect(result.exitCode).not.toBe(0); + expect(result.stdout + result.stderr).toContain( + "--progress requires --json." + ); + } +}, 60_000); + +test("the native backend refuses --progress with a single usage envelope", async () => { + for (const action of ["up", "down", "restart"]) { + const result = await cli([action, "--json", "--progress"], { + HACK_RUNTIME_BACKEND: "native", + }); + expect(result.exitCode).toBe(1); + // A refusal before the stream starts is the ordinary envelope. + const parsed = JSON.parse(result.stdout) as { + ok: boolean; + error?: { code: string; message: string }; + }; + expect(parsed).toEqual({ + ok: false, + error: { + code: "E_USAGE", + message: "Native lifecycle commands do not support --progress yet.", + }, + }); + } +}, 60_000); diff --git a/tests/lifecycle-progress.test.ts b/tests/lifecycle-progress.test.ts new file mode 100644 index 000000000..7665f7d96 --- /dev/null +++ b/tests/lifecycle-progress.test.ts @@ -0,0 +1,162 @@ +import { afterEach, expect, test } from "bun:test"; +import { errorResult, okResult } from "../src/lib/cli-result.ts"; +import { + armLifecycleProgress, + beginLifecycleProgress, + closeLifecycleProgress, + finalServiceEvents, + LIFECYCLE_PROGRESS_SCHEMA, + LifecycleProgressStream, + prepareLifecycleProgress, + reportLifecycleProgress, + resetLifecycleProgress, +} from "../src/lib/lifecycle-progress.ts"; + +const FIXED = new Date("2026-01-02T03:04:05.000Z"); +const TS = FIXED.toISOString(); + +function capture(): { + readonly lines: string[]; + readonly write: (line: string) => void; +} { + const lines: string[] = []; + return { lines, write: (line) => lines.push(line) }; +} + +afterEach(() => { + resetLifecycleProgress(); +}); + +test("a stream writes start, progress and a compact envelope, one line each", () => { + const out = capture(); + const stream = new LifecycleProgressStream({ + action: "up", + write: out.write, + now: () => FIXED, + }); + stream.begin({ project: "shop" }); + stream.progress({ service: null, phase: "images" }); + stream.progress({ service: "api", phase: "installing" }); + const services = { + started: ["api"], + completed: ["migrate"], + stopped: [], + failed: [], + }; + expect(stream.close({ result: okResult({ data: { services } }) })).toBe(true); + // Each write is exactly one newline-terminated line. + expect(out.lines.every((line) => line.split("\n").length === 2)).toBe(true); + expect(out.lines.every((line) => line.endsWith("\n"))).toBe(true); + const parsed = out.lines.map((line) => JSON.parse(line)); + expect(parsed).toEqual([ + { + schema: LIFECYCLE_PROGRESS_SCHEMA, + type: "start", + ts: TS, + action: "up", + project: "shop", + }, + { type: "progress", ts: TS, service: null, phase: "images" }, + { type: "progress", ts: TS, service: "api", phase: "installing" }, + { type: "progress", ts: TS, service: "api", phase: "running" }, + { type: "progress", ts: TS, service: "migrate", phase: "completed" }, + { ok: true, data: { services } }, + ]); + expect(parsed.slice(1).some((line) => "schema" in line)).toBe(false); + expect(out.lines.at(-1)?.startsWith('{"ok":true,')).toBe(true); +}); + +test("nothing is written before begin or after close", () => { + const out = capture(); + const stream = new LifecycleProgressStream({ + action: "down", + write: out.write, + }); + const failure = errorResult({ code: "E_USAGE", message: "x" }); + stream.progress({ service: null, phase: "stopping" }); + expect(stream.close({ result: failure })).toBe(false); + expect(out.lines).toEqual([]); + stream.begin({}); + stream.begin({ project: "again" }); + expect(stream.close({ result: failure })).toBe(true); + stream.progress({ service: null, phase: "stopped" }); + expect(stream.close({ result: failure })).toBe(false); + const parsed = out.lines.map((line) => JSON.parse(line)); + expect(parsed).toEqual([ + expect.objectContaining({ type: "start", action: "down" }), + { ok: false, error: { code: "E_USAGE", message: "x" } }, + ]); + expect(parsed[0]).not.toHaveProperty("project"); +}); + +test("final service events come from the envelope only", () => { + expect( + finalServiceEvents( + errorResult({ + code: "E_STARTUP_INCOMPLETE", + message: "m", + detail: { running: ["api"], completed: [], failed: ["worker"] }, + }) + ) + ).toEqual([ + { service: "api", phase: "running" }, + { service: "worker", phase: "failed" }, + ]); + expect( + finalServiceEvents( + errorResult({ + code: "E_COMPOSE_FAILED", + message: "m", + detail: { exitCode: 3 }, + }) + ) + ).toEqual([]); + expect( + finalServiceEvents( + okResult({ data: { services: { stopped: ["api", 4] } } }) + ) + ).toEqual([{ service: "api", phase: "stopped" }]); +}); + +test("the process-wide stream is a no-op until armed and begun", () => { + const empty = okResult({ data: {} }); + reportLifecycleProgress({ service: null, phase: "images" }); + expect(closeLifecycleProgress({ result: empty })).toBe(false); + const out = capture(); + armLifecycleProgress({ action: "restart", write: out.write }); + reportLifecycleProgress({ service: null, phase: "stopping" }); + expect(out.lines).toEqual([]); + beginLifecycleProgress({ project: "shop" }); + reportLifecycleProgress({ service: null, phase: "stopping" }); + expect(closeLifecycleProgress({ result: empty })).toBe(true); + expect(out.lines).toHaveLength(3); +}); + +test("prepare refuses --progress without --json and on the native backend", () => { + expect(() => + prepareLifecycleProgress({ + action: "up", + progress: true, + json: false, + backend: undefined, + }) + ).toThrow("--progress requires --json."); + expect(() => + prepareLifecycleProgress({ + action: "down", + progress: true, + json: true, + backend: "native", + }) + ).toThrow("Native lifecycle commands do not support --progress yet."); + // Without --progress nothing is validated or armed. + prepareLifecycleProgress({ + action: "up", + progress: false, + json: false, + backend: "native", + }); + expect(closeLifecycleProgress({ result: okResult({ data: {} }) })).toBe( + false + ); +}); From 877d8ca9b94dcd1b22813ebe9038594a9ac9f050 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 18:08:31 +0000 Subject: [PATCH 2/4] fix(lifecycle): close a started --progress stream on thrown errors An exception that escapes after the start line is rendered as the stream's compact closing envelope instead of the global pretty one. Co-Authored-By: Claude Opus 5.5 --- src/cli/run.ts | 17 +++++++++++++++++ tests/lifecycle-progress.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/cli/run.ts b/src/cli/run.ts index 1fc41daea..7c237d851 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -7,6 +7,7 @@ import { HackCliError, } from "../lib/cli-result.ts"; import { setNoInteractiveFlag } from "../lib/interactivity.ts"; +import { closeLifecycleProgress } from "../lib/lifecycle-progress.ts"; import { nativeDetachedUpFailure } from "../lib/native-detached-up.ts"; import { logger } from "../ui/logger.ts"; import { @@ -164,6 +165,19 @@ function resolveSensitiveOutputError( } } +/** + * A started `--progress` stream must still end with one compact envelope + * line; the global pretty envelope would break the ndjson stream. + */ +function closeProgressStreamOnError(error: unknown): boolean { + return closeLifecycleProgress({ + result: + error instanceof CliUsageError + ? errorResult({ code: "E_USAGE", message: error.message }) + : errorResultFromUnknown({ error }), + }); +} + /** * Maps a top-level CLI failure to its exit path. Under `--json`, stdout * stays a single parseable envelope for every error class (usage errors @@ -197,6 +211,9 @@ export async function handleRunCliError(opts: { process.stderr.write(`${JSON.stringify(result)}\n`); return 1; } + if (jsonRequested && closeProgressStreamOnError(error)) { + return 1; + } if (error instanceof CliUsageError) { if (jsonRequested) { // Envelope only: the message is inside it, and logger backends may diff --git a/tests/lifecycle-progress.test.ts b/tests/lifecycle-progress.test.ts index 7665f7d96..ff751278f 100644 --- a/tests/lifecycle-progress.test.ts +++ b/tests/lifecycle-progress.test.ts @@ -160,3 +160,25 @@ test("prepare refuses --progress without --json and on the native backend", () = false ); }); + +test("an error thrown after the stream started still closes it with one compact line", async () => { + const { handleRunCliError } = await import("../src/cli/run.ts"); + const out = capture(); + armLifecycleProgress({ action: "up", write: out.write, now: () => FIXED }); + beginLifecycleProgress({ project: "shop" }); + + const code = await handleRunCliError({ + error: new Error("unexpected failure"), + jsonRequested: true, + }); + + expect(code).toBe(1); + expect(out.lines).toHaveLength(2); + const last = out.lines[1] ?? ""; + expect(last.endsWith("\n")).toBe(true); + expect(last.trimEnd().includes("\n")).toBe(false); + expect(JSON.parse(last)).toMatchObject({ + ok: false, + error: { code: "E_UNEXPECTED" }, + }); +}); From 2f2f2b430df91e34c3564beda63ca4ff11425e2c Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 18:13:26 +0000 Subject: [PATCH 3/4] fix(lifecycle): close --progress on signals and refuse remote targets SIGINT/SIGTERM after start now ends the stream with one compact E_CANCELLED envelope while lifecycle cleanup and signal exit stay as before; a closed stream swallows later envelopes. Remote (dispatch) targets refuse --progress before dispatch, since dispatch output would precede the start line. Co-Authored-By: Claude Opus 5.5 --- docs/reference/lifecycle-progress.md | 10 ++++- src/commands/project.ts | 5 +++ src/lib/lifecycle-progress.ts | 61 ++++++++++++++++++++++++++-- tests/lifecycle-progress-cli.test.ts | 51 +++++++++++++++++++++++ tests/lifecycle-progress.test.ts | 3 +- 5 files changed, 125 insertions(+), 5 deletions(-) diff --git a/docs/reference/lifecycle-progress.md b/docs/reference/lifecycle-progress.md index f8dcdb4a8..f52b6491d 100644 --- a/docs/reference/lifecycle-progress.md +++ b/docs/reference/lifecycle-progress.md @@ -89,7 +89,13 @@ Final per-service lines come from the envelope's `data.services` lists on succes `error.detail.running`, `completed` and `failed`. Other errors have no final per-service lines. -A remote-routed action writes the start line and then its envelope. +## Interruption + +SIGINT or SIGTERM after the start line closes the stream with one compact +`{"ok":false,"error":{"code":"E_CANCELLED",...}}` line. The existing lifecycle +cleanup still runs, and the process still ends by that signal. Nothing is +written to stdout after the closing line. A stream with no closing line was +killed (for example with SIGKILL). ## Refusals @@ -101,6 +107,8 @@ These end with the ordinary pretty `--json` envelope (or usage text without `Native lifecycle commands do not support --progress yet.` before any work. - A native-authored project refuses `--progress` as an unsupported option of its native Compose path. +- A remote target (project lifecycle routed through dispatch): usage error + `--progress is not supported for remote targets.` before dispatch. - Other usage errors, a missing project, Docker missing from `PATH`, and an invalid shared helper configuration. diff --git a/src/commands/project.ts b/src/commands/project.ts index 8db2bfa9c..9f5ed3f0b 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -265,6 +265,7 @@ import { runLifecycleCommandChild } from "../lib/lifecycle-command-child.ts"; import { beginLifecycleProgress, closeLifecycleProgress, + isLifecycleProgressRequested, prepareLifecycleProgress, reportLifecycleProgress, } from "../lib/lifecycle-progress.ts"; @@ -5925,6 +5926,10 @@ async function runRemoteLifecycleCommand(opts: { if (target.resolvedTarget === "local") { return null; } + if (isLifecycleProgressRequested()) { + // Dispatch output would precede `start`; remote targets are unsupported. + throw new CliUsageError("--progress is not supported for remote targets."); + } const projectSelector = await resolveDispatchProjectSelector({ project: opts.project, diff --git a/src/lib/lifecycle-progress.ts b/src/lib/lifecycle-progress.ts index 2ffc01780..c25121972 100644 --- a/src/lib/lifecycle-progress.ts +++ b/src/lib/lifecycle-progress.ts @@ -18,7 +18,7 @@ */ import { CliUsageError } from "../cli/command.ts"; -import { type CliResult, renderCliResult } from "./cli-result.ts"; +import { type CliResult, errorResult, renderCliResult } from "./cli-result.ts"; import type { DependencyStartupPhase } from "./dependency-startup-progress.ts"; import { isRecord } from "./guards.ts"; @@ -104,6 +104,11 @@ export class LifecycleProgressStream { * caller prints the ordinary pretty envelope. */ close(opts: { readonly result: CliResult }): boolean { + if (this.state === "closed") { + // Already closed (e.g. by a signal): swallow later envelopes so the + // compact closing line stays last. + return true; + } if (this.state !== "streaming") { return false; } @@ -215,7 +220,47 @@ export function armLifecycleProgress(opts: { export function beginLifecycleProgress(opts: { readonly project?: string; }): void { - active?.begin(opts); + if (!active) { + return; + } + active.begin(opts); + if (signalHandlers.length === 0) { + installSignalClose(); + } +} + +let signalHandlers: Array void]> = []; + +/** + * SIGINT/SIGTERM after `start` closes the stream with an `E_CANCELLED` + * envelope. Other handlers (lifecycle cleanup) keep running and own the exit; + * when none remain, the signal is raised again so the process still ends by + * that signal, as it would without `--progress`. + */ +function installSignalClose(): void { + for (const signal of ["SIGINT", "SIGTERM"] as const) { + const handler = (): void => { + removeSignalClose(); + active?.close({ + result: errorResult({ + code: "E_CANCELLED", + message: `Interrupted by ${signal}.`, + }), + }); + if (process.listenerCount(signal) === 0) { + process.kill(process.pid, signal); + } + }; + signalHandlers.push([signal, handler]); + process.on(signal, handler); + } +} + +function removeSignalClose(): void { + for (const [signal, handler] of signalHandlers) { + process.off(signal, handler); + } + signalHandlers = []; } /** Report one phase; a no-op unless a stream is armed and started. */ @@ -230,10 +275,20 @@ export function reportLifecycleProgress(event: LifecycleProgressEvent): void { export function closeLifecycleProgress(opts: { readonly result: CliResult; }): boolean { - return active?.close(opts) ?? false; + const closed = active?.close(opts) ?? false; + if (closed) { + removeSignalClose(); + } + return closed; +} + +/** Whether `--progress` was requested for this process (armed or started). */ +export function isLifecycleProgressRequested(): boolean { + return active !== null; } /** Test seam: drop the process-wide stream. */ export function resetLifecycleProgress(): void { + removeSignalClose(); active = null; } diff --git a/tests/lifecycle-progress-cli.test.ts b/tests/lifecycle-progress-cli.test.ts index 6b45225a8..9d670de78 100644 --- a/tests/lifecycle-progress-cli.test.ts +++ b/tests/lifecycle-progress-cli.test.ts @@ -16,6 +16,7 @@ if [ "$1" != "compose" ]; then exit 0; fi case " $* " in *" up "*) if [ "\${STUB_UP_FAIL:-}" = "1" ]; then exit 3; fi + if [ "\${STUB_UP_SLEEP:-}" = "1" ]; then sleep 8; fi exit 0 ;; *" ps "*) case " $* " in @@ -212,3 +213,53 @@ test("the native backend refuses --progress with a single usage envelope", async }); } }, 60_000); + +test("SIGTERM after start closes the stream with one E_CANCELLED line", async () => { + const child = Bun.spawn( + [process.execPath, CLI, "up", "--json", "--progress"], + { + cwd: project, + env: privateCliEnv({ + root, + env: { + PATH: `${bin}:/usr/bin:/bin`, + HACK_RUNTIME_BACKEND: "compose", + HACK_NO_INTERACTIVE: "1", + NO_COLOR: "1", + STUB_UP_SLEEP: "1", + }, + }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + const reader = child.stdout.getReader(); + const decoder = new TextDecoder(); + let stdout = ""; + while (!stdout.includes('"type":"start"')) { + const chunk = await reader.read(); + if (chunk.done) { + break; + } + stdout += decoder.decode(chunk.value, { stream: true }); + } + child.kill("SIGTERM"); + for (;;) { + const chunk = await reader.read(); + if (chunk.done) { + break; + } + stdout += decoder.decode(chunk.value, { stream: true }); + } + await child.exited; + + const lines = ndjson(stdout); + expect(lines[0]).toMatchObject({ type: "start", action: "up" }); + expect(lines.at(-1)).toMatchObject({ + ok: false, + error: { code: "E_CANCELLED" }, + }); + expect(lines.filter((line) => "ok" in line)).toHaveLength(1); + expect(child.exitCode === 0).toBe(false); +}, 60_000); diff --git a/tests/lifecycle-progress.test.ts b/tests/lifecycle-progress.test.ts index ff751278f..667da93a0 100644 --- a/tests/lifecycle-progress.test.ts +++ b/tests/lifecycle-progress.test.ts @@ -80,7 +80,8 @@ test("nothing is written before begin or after close", () => { stream.begin({ project: "again" }); expect(stream.close({ result: failure })).toBe(true); stream.progress({ service: null, phase: "stopped" }); - expect(stream.close({ result: failure })).toBe(false); + // Closed: later envelopes are swallowed, not printed. + expect(stream.close({ result: failure })).toBe(true); const parsed = out.lines.map((line) => JSON.parse(line)); expect(parsed).toEqual([ expect.objectContaining({ type: "start", action: "down" }), From dfa6af86df9400ced9735d7e5c022300c674b1ae Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 18:20:13 +0000 Subject: [PATCH 4/4] fix(lifecycle): make --progress signal close reliable Install the signal handlers when the stream is armed rather than at start, and write stream lines synchronously, so a signal right after start cannot kill the process before the closing envelope is written. Co-Authored-By: Claude Opus 5.5 --- src/lib/lifecycle-progress.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/lib/lifecycle-progress.ts b/src/lib/lifecycle-progress.ts index c25121972..768809569 100644 --- a/src/lib/lifecycle-progress.ts +++ b/src/lib/lifecycle-progress.ts @@ -17,6 +17,7 @@ * did not happen is skipped, never synthesized. */ +import { writeSync } from "node:fs"; import { CliUsageError } from "../cli/command.ts"; import { type CliResult, errorResult, renderCliResult } from "./cli-result.ts"; import type { DependencyStartupPhase } from "./dependency-startup-progress.ts"; @@ -210,9 +211,16 @@ export function armLifecycleProgress(opts: { }): LifecycleProgressStream { active = new LifecycleProgressStream({ action: opts.action, - write: opts.write ?? ((line) => process.stdout.write(line)), + // Synchronous: the signal path closes the stream and then re-raises the + // signal, which would drop a write still queued on a stdout pipe. + write: opts.write ?? ((line) => writeSync(1, line)), now: opts.now, }); + // Installed when armed, well before `start`: signal handlers may register + // asynchronously, and a signal before `start` closes nothing (close is a + // no-op while armed) and still ends the process by that signal. + removeSignalClose(); + installSignalClose(); return active; } @@ -224,16 +232,13 @@ export function beginLifecycleProgress(opts: { return; } active.begin(opts); - if (signalHandlers.length === 0) { - installSignalClose(); - } } let signalHandlers: Array void]> = []; /** * SIGINT/SIGTERM after `start` closes the stream with an `E_CANCELLED` - * envelope. Other handlers (lifecycle cleanup) keep running and own the exit; + * envelope; before `start` it writes nothing. Other handlers (lifecycle cleanup) keep running and own the exit; * when none remain, the signal is raised again so the process still ends by * that signal, as it would without `--progress`. */