Skip to content

Commit 2f4a4be

Browse files
committed
fix(runtime): persist wrapped command process group
1 parent 57f2358 commit 2f4a4be

5 files changed

Lines changed: 154 additions & 26 deletions

File tree

docs/architecture.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,10 @@ Projects can run host-side hooks around `hack up/down` and start managed host pr
122122
local proxies/tunnels). Processes are started inside a mux session (tmux or zellij) so they have a
123123
stable home and can be torn down on `hack down`.
124124

125-
Lifecycle state is checkout-local at `.hack/.internal/lifecycle/state.json`. Each compose instance
126-
has its own entry, so starting a branch instance does not replace the base instance's entry in the
127-
same checkout. Cleanup reconciles the saved process group with the live process table; if the group
125+
Lifecycle state is checkout-local at `.hack/.internal/lifecycle/state.json`. For tmux processes, the
126+
saved process group belongs to the wrapped user command rather than its mux pane shell. Each compose
127+
instance has its own entry, so starting a branch instance does not replace the base instance's entry
128+
in the same checkout. Cleanup reconciles the saved process group with the live process table; if the group
128129
leader exited while members remain, `hack doctor` reports the orphan. `hack down` terminates the
129130
persisted leaderless group and descendant process groups only while a matching lifecycle mux session
130131
still proves ownership; without that session, cleanup stays non-destructive because the numeric PGID

docs/lifecycle.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,8 @@ Lifecycle session name:
212212
Notes:
213213
- If no mux backend is available, lifecycle process startup fails with an actionable error.
214214
- Teardown is implemented by killing the lifecycle session; anything running inside that session will be stopped.
215-
- For tmux-backed lifecycle sessions, Hack also persists pane PID and process-group metadata to
216-
`.hack/.internal/lifecycle/state.json`, with per-hook/process output logged to
215+
- For tmux-backed lifecycle sessions, Hack also persists the pane PID and the wrapped command's actual
216+
process-group metadata to `.hack/.internal/lifecycle/state.json`, with per-hook/process output logged to
217217
`.hack/.internal/lifecycle/*.log`. If tmux pane state disappears before teardown, `hack down` still uses that persisted metadata to clean up any live lifecycle process groups instead of leaving orphaned host processes behind.
218218
- `hack doctor` reports stale lifecycle state when the persisted lifecycle entry no longer has a live mux session and points operators to `hack down` so cleanup and state removal happen through the supported path. `hack doctor --fix` does not tear down lifecycle sessions itself — use `hack down` for that.
219219

src/commands/project.ts

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { rm } from "node:fs/promises";
12
import { dirname, resolve } from "node:path";
23
import {
34
autocompleteMultiselect,
@@ -210,6 +211,9 @@ const LABELS_LINE_PATTERN = /^(\s*)labels:\s*$/;
210211
/** Regex to extract leading whitespace (indentation). */
211212
const INDENT_PATTERN = /^(\s*)/;
212213

214+
const LIFECYCLE_COMMAND_PID_WAIT_ATTEMPTS = 50;
215+
const LIFECYCLE_COMMAND_PID_WAIT_INTERVAL_MS = 20;
216+
213217
/** Regex to match caddy label line in YAML. */
214218
const CADDY_LABEL_PATTERN = /^(\s*)caddy:\s*(.*)$/;
215219

@@ -1974,13 +1978,16 @@ async function startLifecycleProcess(opts: {
19741978
projectDir: opts.projectDir,
19751979
composeProject: opts.composeProject,
19761980
});
1977-
const wrappedCommand = wrapLifecyclePersistentCommand({
1978-
command: opts.process.command,
1979-
logPath,
1980-
serviceName: opts.process.name,
1981-
});
1982-
19831981
if (opts.backend === "tmux") {
1982+
const commandPidPath = `${logPath}.${windowName}.pid`;
1983+
await ensureDir(dirname(commandPidPath));
1984+
await rm(commandPidPath, { force: true });
1985+
const wrappedCommand = wrapLifecyclePersistentCommand({
1986+
command: opts.process.command,
1987+
commandPidPath,
1988+
logPath,
1989+
serviceName: opts.process.name,
1990+
});
19841991
const result = await exec(
19851992
[
19861993
"tmux",
@@ -1998,6 +2005,7 @@ async function startLifecycleProcess(opts: {
19982005
{ stdin: "ignore" }
19992006
);
20002007
if (result.exitCode !== 0) {
2008+
await rm(commandPidPath, { force: true });
20012009
throw new Error(
20022010
`Failed to start lifecycle process "${opts.process.name}": ${result.stderr.trim()}`
20032011
);
@@ -2007,10 +2015,17 @@ async function startLifecycleProcess(opts: {
20072015
windowName,
20082016
});
20092017
const panePid = panePids[0];
2010-
const processGroupId =
2011-
panePid !== undefined
2012-
? await readProcessGroupIdForPid({ pid: panePid })
2013-
: null;
2018+
let processGroupId: number | null = null;
2019+
try {
2020+
processGroupId = await waitForLifecycleCommandProcessGroupId({
2021+
commandPidPath,
2022+
});
2023+
} finally {
2024+
await rm(commandPidPath, { force: true });
2025+
}
2026+
if (!processGroupId && panePid !== undefined) {
2027+
processGroupId = await readProcessGroupIdForPid({ pid: panePid });
2028+
}
20142029
await appendLifecycleLogRecord({
20152030
projectDir: opts.projectDir,
20162031
composeProject: opts.composeProject,
@@ -2030,6 +2045,12 @@ async function startLifecycleProcess(opts: {
20302045
};
20312046
}
20322047

2048+
const wrappedCommand = wrapLifecyclePersistentCommand({
2049+
command: opts.process.command,
2050+
commandPidPath: null,
2051+
logPath,
2052+
serviceName: opts.process.name,
2053+
});
20332054
const result = await exec(
20342055
["zellij", "run", "--", "sh", "-c", wrappedCommand],
20352056
{
@@ -2189,6 +2210,29 @@ async function readProcessGroupIdForPid(opts: {
21892210
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
21902211
}
21912212

2213+
async function waitForLifecycleCommandProcessGroupId(opts: {
2214+
readonly commandPidPath: string;
2215+
}): Promise<number | null> {
2216+
for (
2217+
let attempt = 0;
2218+
attempt < LIFECYCLE_COMMAND_PID_WAIT_ATTEMPTS;
2219+
attempt += 1
2220+
) {
2221+
const commandPidRaw = await readTextFile(opts.commandPidPath);
2222+
const commandPid = Number.parseInt(commandPidRaw?.trim() ?? "", 10);
2223+
if (Number.isInteger(commandPid) && commandPid > 1) {
2224+
const processGroupId = await readProcessGroupIdForPid({
2225+
pid: commandPid,
2226+
});
2227+
if (processGroupId) {
2228+
return processGroupId;
2229+
}
2230+
}
2231+
await Bun.sleep(LIFECYCLE_COMMAND_PID_WAIT_INTERVAL_MS);
2232+
}
2233+
return null;
2234+
}
2235+
21922236
async function resolveLifecycleProcessGroupIds(opts: {
21932237
readonly sessionName: string;
21942238
readonly lifecycleEntry: LifecycleStateEntry | null;
@@ -2267,16 +2311,22 @@ function resolveLifecycleCommandServiceName(opts: {
22672311

22682312
export function wrapLifecyclePersistentCommand(opts: {
22692313
readonly command: string;
2314+
readonly commandPidPath: string | null;
22702315
readonly logPath: string;
22712316
readonly serviceName: string;
22722317
}): string {
22732318
const logPath = shellSingleQuote(opts.logPath);
22742319
const service = shellSingleQuote(opts.serviceName);
22752320
const command = shellSingleQuote(opts.command);
2321+
const commandPidPath =
2322+
opts.commandPidPath === null ? null : shellSingleQuote(opts.commandPidPath);
22762323
return [
22772324
`HACK_LIFECYCLE_LOG=${logPath}`,
22782325
`HACK_LIFECYCLE_SERVICE=${service}`,
22792326
`HACK_LIFECYCLE_COMMAND=${command}`,
2327+
...(commandPidPath
2328+
? [`HACK_LIFECYCLE_COMMAND_PID_FILE=${commandPidPath}`]
2329+
: []),
22802330
`fifo="$(mktemp -u "\${TMPDIR:-/tmp}/hack-lifecycle.XXXXXX")"`,
22812331
'mkfifo "$fifo"',
22822332
"cleanup_lifecycle() {",
@@ -2294,6 +2344,7 @@ export function wrapLifecyclePersistentCommand(opts: {
22942344
` if [ -n "\${reader_pid:-}" ]; then`,
22952345
' wait "$reader_pid" 2>/dev/null || true',
22962346
" fi",
2347+
...(commandPidPath ? [' rm -f "$HACK_LIFECYCLE_COMMAND_PID_FILE"'] : []),
22972348
' rm -f "$fifo"',
22982349
"}",
22992350
'trap "cleanup_lifecycle; exit 130" INT TERM HUP',
@@ -2304,11 +2355,21 @@ export function wrapLifecyclePersistentCommand(opts: {
23042355
' done < "$fifo" ) &',
23052356
"reader_pid=$!",
23062357
"if command -v python3 >/dev/null 2>&1; then",
2307-
' python3 -c \'import os, sys; os.setsid(); os.execvp("sh", ["sh", "-c", sys.argv[1]])\' "$HACK_LIFECYCLE_COMMAND" >"$fifo" 2>&1 &',
2358+
...(commandPidPath
2359+
? [
2360+
' python3 -c \'import os, sys; os.setsid(); pid_file = open(sys.argv[2], "w"); pid_file.write(str(os.getpid())); pid_file.close(); os.execvp("sh", ["sh", "-c", sys.argv[1]])\' "$HACK_LIFECYCLE_COMMAND" "$HACK_LIFECYCLE_COMMAND_PID_FILE" >"$fifo" 2>&1 &',
2361+
]
2362+
: [
2363+
' python3 -c \'import os, sys; os.setsid(); os.execvp("sh", ["sh", "-c", sys.argv[1]])\' "$HACK_LIFECYCLE_COMMAND" >"$fifo" 2>&1 &',
2364+
]),
2365+
" cmd_pid=$!",
23082366
"else",
23092367
' sh -c "$HACK_LIFECYCLE_COMMAND" >"$fifo" 2>&1 &',
2368+
" cmd_pid=$!",
2369+
...(commandPidPath
2370+
? [' printf "%s\\n" "$cmd_pid" > "$HACK_LIFECYCLE_COMMAND_PID_FILE"']
2371+
: []),
23102372
"fi",
2311-
"cmd_pid=$!",
23122373
'wait "$cmd_pid"',
23132374
"cmd_status=$?",
23142375
'cmd_pid=""',

tests/e2e/scenarios/lifecycle-host-process.ts

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,44 @@
11
import { join } from "node:path";
22

33
import { createMonorepoFixture } from "../fixture.ts";
4-
import { expect, expectExit, type Scenario } from "../harness.ts";
4+
import { expect, expectExit, runCommand, type Scenario } from "../harness.ts";
55
import { downBestEffort, requireDockerPreconditions } from "./docker-shared.ts";
66

77
const UP_TIMEOUT_MS = 420_000;
88

9+
type LifecycleStateFile = {
10+
readonly entries?: readonly {
11+
readonly processes?: readonly {
12+
readonly name?: string;
13+
readonly panePid?: number;
14+
readonly processGroupId?: number;
15+
}[];
16+
}[];
17+
};
18+
919
/**
1020
* Lifecycle host hooks run as part of `hack up` (there is no standalone
1121
* lifecycle start/stop command today), so this scenario lives in the docker
1222
* tier: `hack up --detach` must execute the configured lifecycle.up.before
1323
* hook on the host (observed via a marker file) before starting services,
14-
* and record lifecycle runtime state under .hack/.internal/lifecycle/.
24+
* record lifecycle runtime state under .hack/.internal/lifecycle/, and retain
25+
* the wrapped command's actual process group for teardown.
1526
*/
1627
export const lifecycleHostProcessScenario: Scenario = {
1728
name: "lifecycle-host-process",
1829
tier: "docker",
19-
summary: "lifecycle.up.before host hook runs during hack up",
30+
summary: "lifecycle hooks and command-group teardown run during up/down",
2031
run: async (ctx) => {
2132
await requireDockerPreconditions({ ctx });
2233

2334
const markerFile = join(ctx.tempRoot, "lifecycle-marker.txt");
2435
const fixture = await createMonorepoFixture({
2536
parentDir: ctx.tempRoot,
2637
withHackConfig: true,
27-
lifecycle: { upBeforeMarkerFile: markerFile },
38+
lifecycle: {
39+
persistentProcess: true,
40+
upBeforeMarkerFile: markerFile,
41+
},
2842
});
2943

3044
try {
@@ -54,12 +68,57 @@ export const lifecycleHostProcessScenario: Scenario = {
5468
const stateFile = Bun.file(
5569
join(fixture.hackDir, ".internal", "lifecycle", "state.json")
5670
);
57-
const hasState = await stateFile.exists();
58-
ctx.log(
59-
hasState
60-
? "lifecycle state recorded at .hack/.internal/lifecycle/state.json"
61-
: "note: no lifecycle state.json written (no persistent processes configured)"
71+
expect({
72+
that: await stateFile.exists(),
73+
message: "persistent lifecycle process did not write lifecycle state",
74+
result: up,
75+
});
76+
const state = (await stateFile.json()) as LifecycleStateFile;
77+
const sleeper = state.entries?.[0]?.processes?.find(
78+
(process) => process.name === "e2e-sleeper"
6279
);
80+
const panePid = sleeper?.panePid ?? 0;
81+
const processGroupId = sleeper?.processGroupId ?? 0;
82+
expect({
83+
that:
84+
Number.isInteger(panePid) &&
85+
panePid > 1 &&
86+
Number.isInteger(processGroupId) &&
87+
processGroupId > 1 &&
88+
processGroupId !== panePid,
89+
message:
90+
"lifecycle state should persist the wrapped command group, not the tmux pane group",
91+
result: up,
92+
});
93+
94+
const groupLeader = await runCommand({
95+
argv: ["ps", "-o", "pgid=", "-p", String(processGroupId)],
96+
cwd: fixture.root,
97+
});
98+
expectExit({
99+
result: groupLeader,
100+
codes: [0],
101+
message: "persisted lifecycle command group leader should be live",
102+
});
103+
expect({
104+
that: Number.parseInt(groupLeader.stdout.trim(), 10) === processGroupId,
105+
message:
106+
"persisted lifecycle PGID does not match the live command group",
107+
result: groupLeader,
108+
});
109+
110+
await downBestEffort({ ctx, fixture });
111+
let commandGroupStillExists = true;
112+
try {
113+
process.kill(-processGroupId, 0);
114+
} catch {
115+
commandGroupStillExists = false;
116+
}
117+
expect({
118+
that: !commandGroupStillExists,
119+
message: "hack down left the lifecycle command process group alive",
120+
result: up,
121+
});
63122
} finally {
64123
await downBestEffort({ ctx, fixture });
65124
}

tests/project-lifecycle-processes.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,7 @@ test("resolveLifecycleStopProcessGroupIds recovers a leaderless persisted group
339339
test("wrapLifecyclePersistentCommand uses external kill for process-group cleanup", () => {
340340
const script = wrapLifecyclePersistentCommand({
341341
command: "bun run proxy",
342+
commandPidPath: "/tmp/event-agent.pid",
342343
logPath: "/tmp/event-agent.log",
343344
serviceName: "proxy",
344345
});
@@ -353,12 +354,18 @@ test("wrapLifecyclePersistentCommand uses external kill for process-group cleanu
353354
test("wrapLifecyclePersistentCommand avoids login-shell execution", () => {
354355
const script = wrapLifecyclePersistentCommand({
355356
command: "bun run proxy",
357+
commandPidPath: "/tmp/event-agent.pid",
356358
logPath: "/tmp/event-agent.log",
357359
serviceName: "proxy",
358360
});
359361

360362
expect(script).toContain('os.execvp("sh", ["sh", "-c", sys.argv[1]])');
361363
expect(script).toContain('sh -c "$HACK_LIFECYCLE_COMMAND" >"$fifo" 2>&1 &');
364+
expect(script).toContain("os.setsid()");
365+
expect(script).toContain("pid_file.write(str(os.getpid()))");
366+
expect(script).toContain(
367+
'printf "%s\\n" "$cmd_pid" > "$HACK_LIFECYCLE_COMMAND_PID_FILE"'
368+
);
362369
expect(script).not.toContain('os.execvp("sh", ["sh", "-lc", sys.argv[1]])');
363370
expect(script).not.toContain(
364371
'sh -lc "$HACK_LIFECYCLE_COMMAND" >"$fifo" 2>&1 &'

0 commit comments

Comments
 (0)