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
7 changes: 7 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,13 @@ perform full inspection so missed events and mutable network data remain eventua

If the daemon is not running (or version-mismatched), the CLI falls back to direct Docker calls.

Daemon startup and `hack daemon clear` only terminate untracked daemon processes
when `lsof` confirms they hold a socket in the target daemon state directory.
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.

Runtime health:
- The daemon treats the container runtime as ephemeral; it fingerprints the engine (socket + engine id)
and detects resets.
Expand Down
10 changes: 10 additions & 0 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,16 @@ hack host exec --env qa --scope api -- bun db:migrate
hack host exec --env qa --scope api --target compose -- bun test
```

Cancelling `hack host exec` or `hack env exec` with SIGINT or SIGTERM forwards
the signal to the command. Commands run in an owned process group,
so cancellation also stops their descendants, escalating to SIGKILL after two
seconds if necessary. Hack returns 130 for SIGINT and 143 for SIGTERM. Interactive
commands use a supervisor in their own foreground group on the same terminal,
preserving stdin, separate output streams, and Ctrl-Z/foreground resume. The
supervisor holds group ownership until cancellation cleanup finishes. SIGKILL cannot be forwarded; supervisors must terminate the
whole owned process tree when force-killing a wrapper. Commands have no implicit
time limit, and normal completion preserves the command's exit status.

When you want to inspect an injected value, avoid `hack host exec -- echo $VAR`. Your current
shell expands `$VAR` before Hack starts the child process, so the command often sees an empty
string.
Expand Down
12 changes: 9 additions & 3 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
#!/usr/bin/env bun

import { runCli } from "./packages/cli/index.ts";
import {
runTtySupervisor,
TTY_SUPERVISOR_ARGUMENT,
} from "./src/lib/tty-supervisor.ts";

const exitCode = await runCli(Bun.argv.slice(2));
process.exitCode = exitCode;
if (Bun.argv[2] === TTY_SUPERVISOR_ARGUMENT && process.send) {
process.exit(await runTtySupervisor());
}
const { runCli } = await import("./packages/cli/index.ts");
process.exitCode = await runCli(Bun.argv.slice(2));
2 changes: 2 additions & 0 deletions src/commands/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ async function handleDaemonStart({
// make every freshly spawned daemon exit cleanly. Sweep them first.
const orphans = await findOrphanDaemonProcesses({
trackedPid: status.pid,
daemonRoot: paths.root,
});
if (orphans.length > 0) {
logger.warn({
Expand Down Expand Up @@ -603,6 +604,7 @@ async function handleDaemonClear({

const orphans = await findOrphanDaemonProcesses({
trackedPid: status.pid,
daemonRoot: paths.root,
});
if (orphans.length > 0) {
logger.warn({
Expand Down
1 change: 1 addition & 0 deletions src/commands/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1925,6 +1925,7 @@ async function runHostCommandWithInjectedEnv(input: {
cwd: project.projectRoot,
env: envState.env,
stdin: "inherit",
forwardSignals: true,
}
);
}
Expand Down
72 changes: 71 additions & 1 deletion src/daemon/process.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { realpath } from "node:fs/promises";
import { resolve } from "node:path";

import { readTextFile, writeTextFile } from "../lib/fs.ts";
import { exec, findExecutableInPath } from "../lib/shell.ts";

export async function readDaemonPid({
pidPath,
Expand Down Expand Up @@ -79,12 +83,18 @@ const WHITESPACE_PATTERN = /\s+/;
* pid file — the root cause of "daemon starts then exits" contradictions:
* the orphan holds the API while every newly spawned daemon exits.
*
* Only processes holding a socket in the target daemon directory are eligible.
* A command-name match alone cannot distinguish another HOME/HACK_HOME daemon.
* Missing ownership evidence must never authorize termination.
*
* @param opts.trackedPid - pid currently recorded in the pid file, if any.
* @param opts.psLines - injectable `ps -axo pid=,command=` lines for tests.
*/
export async function findOrphanDaemonProcesses(opts: {
readonly trackedPid: number | null;
readonly daemonRoot: string;
readonly psLines?: readonly string[];
readonly lsofLines?: readonly string[];
}): Promise<readonly number[]> {
const lines = opts.psLines ?? (await listProcessTable());
const orphans: number[] = [];
Expand All @@ -110,7 +120,67 @@ export async function findOrphanDaemonProcesses(opts: {
}
orphans.push(pid);
}
return orphans;
if (orphans.length === 0) {
return [];
}
const owners = await findDaemonSocketOwners({
daemonRoot: opts.daemonRoot,
lines: opts.lsofLines ?? (await listOpenUnixSockets({ pids: orphans })),
});
return orphans.filter((orphan) => owners.has(orphan));
}

async function findDaemonSocketOwners(opts: {
readonly daemonRoot: string;
readonly lines: readonly string[];
}): Promise<ReadonlySet<number>> {
const roots = new Set([resolve(opts.daemonRoot)]);
try {
roots.add(await realpath(opts.daemonRoot));
} catch {
// A deleted directory can still appear in an open socket's pathname.
}
const socketPaths = new Set(
[...roots].flatMap((root) =>
["hackd.sock", "hackd.internal.sock", "gateway.internal.sock"].map(
(name) => resolve(root, name)
)
)
);
const owners = new Set<number>();
let pid: number | null = null;
for (const line of opts.lines) {
if (line.startsWith("p")) {
pid = Number.parseInt(line.slice(1), 10);
} else if (
pid !== null &&
line.startsWith("n") &&
socketPaths.has(line.slice(1))
) {
owners.add(pid);
}
}
return owners;
}

async function listOpenUnixSockets(opts: {
readonly pids: readonly number[];
}): Promise<readonly string[]> {
const lsof = findExecutableInPath("lsof");
if (!lsof) {
return [];
}
try {
const result = await exec(
// Socket names do not need filesystem stat/readlink calls, which can
// block on unrelated mounted filesystems during daemon recovery.
[lsof, "-nP", "-b", "-w", "-a", "-U", "-p", opts.pids.join(","), "-Fpn"],
{ stdin: "ignore", timeoutMs: 3000 }
);
return result.exitCode === 0 ? result.stdout.split("\n") : [];
} catch {
return [];
}
}

/**
Expand Down
77 changes: 73 additions & 4 deletions src/lib/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,27 +73,96 @@ export interface RunOptions {
*/
readonly stdout?: "inherit" | "stderr";
readonly timeoutMs?: number;
/** Forward cancellation to an owned command process group, preserving TTY input. */
readonly forwardSignals?: boolean;
}

export async function run(
cmd: readonly string[],
opts: RunOptions = {}
): Promise<number> {
if (
opts.forwardSignals &&
process.stdin.isTTY &&
(opts.stdin ?? "inherit") === "inherit"
) {
const { runWithTerminalGroup } = await import("./tty-run.ts");
return await runWithTerminalGroup({
command: cmd,
cwd: opts.cwd,
env: buildSpawnEnv(opts.env),
stdout: opts.stdout,
timeoutMs: opts.timeoutMs,
});
}
const ownsProcessGroup =
opts.timeoutMs !== undefined || opts.forwardSignals === true;
const proc = Bun.spawn([...cmd], {
cwd: opts.cwd,
env: buildSpawnEnv(opts.env),
stdin: opts.stdin ?? "inherit",
stdout: opts.stdout === "stderr" ? 2 : "inherit",
stderr: "inherit",
detached: opts.timeoutMs !== undefined,
detached: ownsProcessGroup,
Comment thread
roodboi marked this conversation as resolved.
});
const timeout = installSubprocessTimeout({
pid: proc.pid,
timeoutMs: opts.timeoutMs,
});
const exitCode = await proc.exited;
timeout.dispose();
return timeout.didTimeout() ? 124 : exitCode;
const cancellation = opts.forwardSignals
? installSubprocessSignalForwarding({
pid: proc.pid,
})
: null;
try {
const exitCode = await proc.exited;
return cancellation?.exitCode() ?? (timeout.didTimeout() ? 124 : exitCode);
} finally {
timeout.dispose();
cancellation?.dispose();
}
}

/** Detached noninteractive children keep cancellation scoped to their group. */
function installSubprocessSignalForwarding(opts: { readonly pid: number }): {
readonly dispose: () => void;
readonly exitCode: () => number | null;
} {
let exitCode: number | null = null;
let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
const send = (signal: NodeJS.Signals): void => {
try {
process.kill(-opts.pid, signal);
} catch {
// The owned group may already have exited.
}
};
const cancel = (signal: "SIGINT" | "SIGTERM"): void => {
if (exitCode !== null) {
send("SIGKILL");
return;
}
exitCode = signal === "SIGINT" ? 130 : 143;
send(signal);
forceKillTimer = setTimeout(() => send("SIGKILL"), 2000);
};
const onInterrupt = (): void => cancel("SIGINT");
const onTerminate = (): void => cancel("SIGTERM");
process.on("SIGINT", onInterrupt);
process.on("SIGTERM", onTerminate);
return {
exitCode: () => exitCode,
dispose: () => {
process.off("SIGINT", onInterrupt);
process.off("SIGTERM", onTerminate);
if (forceKillTimer) {
clearTimeout(forceKillTimer);
}
if (exitCode !== null) {
send("SIGKILL");
}
},
};
}

function installSubprocessTimeout(opts: {
Expand Down
58 changes: 58 additions & 0 deletions src/lib/tty-process-group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { dlopen, FFIType, type Pointer } from "bun:ffi";
import { constants } from "node:os";

/** POSIX job control without a proxy PTY: all three command streams stay intact. */
export function openTerminalControl() {
const symbols = {
setpgid: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
getpgrp: { args: [], returns: FFIType.i32 },
tcgetpgrp: { args: [FFIType.i32], returns: FFIType.i32 },
tcsetpgrp: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
tcgetattr: { args: [FFIType.i32, FFIType.ptr], returns: FFIType.i32 },
tcsetattr: {
args: [FFIType.i32, FFIType.i32, FFIType.ptr],
returns: FFIType.i32,
},
signal: { args: [FFIType.i32, FFIType.ptr], returns: FFIType.ptr },
} as const;
const library =
process.platform === "darwin" ? "/usr/lib/libSystem.B.dylib" : "libc.so.6";
const libc = dlopen(library, symbols);
function withoutBackgroundStop<T>(operation: () => T): T {
const previous = libc.symbols.signal(
constants.signals.SIGTTOU,
1 as Pointer
);
try {
return operation();
} finally {
libc.symbols.signal(constants.signals.SIGTTOU, previous);
}
}
return {
createGroup: () => libc.symbols.setpgid(0, 0) === 0,
group: () => libc.symbols.getpgrp(),
foreground: () => libc.symbols.tcgetpgrp(0),
setForeground: (group: number) =>
withoutBackgroundStop(() => libc.symbols.tcsetpgrp(0, group) === 0),
attributes: () => {
// Opaque termios storage, larger than both Darwin and Linux structures.
const value = new Uint8Array(256);
return libc.symbols.tcgetattr(0, value) === 0 ? value : null;
},
restoreAttributes: (value: Uint8Array | null) => {
if (value) {
withoutBackgroundStop(() => libc.symbols.tcsetattr(0, 0, value));
}
},
close: () => libc.close(),
};
}

export function signalOwnedGroup(pid: number, signal: NodeJS.Signals): void {
try {
process.kill(-pid, signal);
} catch {
// The owned group may already have exited.
}
}
Loading
Loading