Skip to content
4 changes: 4 additions & 0 deletions scripts/run-tests-deterministic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const isolatedTestPaths = [
"tests/runtime-projects.test.ts",
"tests/projects-registry.test.ts",
"tests/project-run-command.test.ts",
"tests/project-exec-command.test.ts",
"tests/cli-offline-optionality-matrix.test.ts",
"tests/session-env-command.test.ts",
] as const;
Expand Down Expand Up @@ -84,6 +85,9 @@ function testLabel(opts: { readonly testPath: string }): string {
if (opts.testPath === "tests/project-run-command.test.ts") {
return "project run command";
}
if (opts.testPath === "tests/project-exec-command.test.ts") {
return "project exec command";
}
if (opts.testPath === "tests/cli-offline-optionality-matrix.test.ts") {
return "offline optionality matrix";
}
Expand Down
301 changes: 296 additions & 5 deletions src/commands/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import { resolveHackEnv, upsertDotEnvValue } from "../lib/hack-env.ts";
import { parseJsonLines } from "../lib/json-lines.ts";
import {
appendLifecycleLogRecord,
type LifecycleStateEntry,
readLifecycleState,
removeLifecycleStateEntry,
resolveLifecycleComposeProjectName,
Expand Down Expand Up @@ -168,6 +169,7 @@ const CADDY_LABEL_PATTERN = /^(\s*)caddy:\s*(.*)$/;

/** Regex to check if a string starts with a URL scheme (e.g., "http://", "https://"). */
const URL_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
const WHITESPACE_PATTERN = /\s+/;

const optManual = defineOption({
name: "manual",
Expand Down Expand Up @@ -1711,6 +1713,8 @@ async function startLifecycleProcess(opts: {
readonly name: string;
readonly windowName: string;
readonly logPath: string;
readonly panePid?: number;
readonly processGroupId?: number;
}> {
const windowNameRaw = sanitizeBranchSlug(opts.process.name);
const windowName =
Expand Down Expand Up @@ -1751,6 +1755,15 @@ async function startLifecycleProcess(opts: {
`Failed to start lifecycle process "${opts.process.name}": ${result.stderr.trim()}`
);
}
const panePids = await readTmuxPanePids({
sessionName: opts.sessionName,
windowName,
});
const panePid = panePids[0];
const processGroupId =
panePid !== undefined
? await readProcessGroupIdForPid({ pid: panePid })
: null;
await appendLifecycleLogRecord({
projectDir: opts.projectDir,
composeProject: opts.composeProject,
Expand All @@ -1765,6 +1778,8 @@ async function startLifecycleProcess(opts: {
name: opts.process.name,
windowName,
logPath,
...(panePid !== undefined ? { panePid } : {}),
...(processGroupId ? { processGroupId } : {}),
};
}

Expand Down Expand Up @@ -1818,6 +1833,13 @@ async function stopLifecycleProcesses(opts: {
projectName: opts.projectName,
branch: opts.branch,
});
const lifecycleEntries = await readLifecycleState({
projectDir: opts.project.projectDir,
});
const lifecycleEntry =
lifecycleEntries.find(
(entry) => entry.composeProject === opts.composeProject
) ?? null;

const backends = getMuxBackends();
for (const backend of backends.values()) {
Expand All @@ -1828,6 +1850,12 @@ async function stopLifecycleProcesses(opts: {
if (!sessions.some((s) => s.name === sessionName)) {
continue;
}
if (backend.name === "tmux") {
await interruptLifecycleTmuxProcesses({
sessionName,
lifecycleEntry,
});
}
await backend.killSession({ name: sessionName });
}

Expand All @@ -1837,6 +1865,232 @@ async function stopLifecycleProcesses(opts: {
});
}

async function interruptLifecycleTmuxProcesses(opts: {
readonly sessionName: string;
readonly lifecycleEntry: LifecycleStateEntry | null;
}): Promise<void> {
const processWindows = opts.lifecycleEntry?.processes ?? [];
if (processWindows.length === 0) {
return;
}

for (const processInfo of processWindows) {
await exec(
[
"tmux",
"send-keys",
"-t",
`${opts.sessionName}:${processInfo.windowName}`,
"C-c",
],
{ stdin: "ignore" }
);
}

await Bun.sleep(750);
await terminateLifecycleProcessGroups({
processGroupIds: await resolveLifecycleProcessGroupIds({
sessionName: opts.sessionName,
lifecycleEntry: opts.lifecycleEntry,
}),
});
}

type ProcessSnapshotRow = {
readonly pid: number;
readonly ppid: number;
readonly processGroupId: number;
};

async function readTmuxPanePids(opts: {
readonly sessionName: string;
readonly windowName: string;
}): Promise<number[]> {
const result = await exec(
[
"tmux",
"list-panes",
"-t",
`${opts.sessionName}:${opts.windowName}`,
"-F",
"#{pane_pid}",
],
{ stdin: "ignore" }
);
if (result.exitCode !== 0) {
return [];
}
return result.stdout
.split("\n")
.map((line) => Number.parseInt(line.trim(), 10))
.filter((value) => Number.isInteger(value) && value > 0);
}

async function readProcessGroupIdForPid(opts: {
readonly pid: number;
}): Promise<number | null> {
const result = await exec(["ps", "-o", "pgid=", "-p", String(opts.pid)], {
stdin: "ignore",
});
if (result.exitCode !== 0) {
return null;
}
const parsed = Number.parseInt(result.stdout.trim(), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}

async function readProcessSnapshot(): Promise<ProcessSnapshotRow[]> {
const result = await exec(["ps", "-axo", "pid=,ppid=,pgid="], {
stdin: "ignore",
});
if (result.exitCode !== 0) {
return [];
}
return parseProcessSnapshotOutput(result.stdout);
}

export function parseProcessSnapshotOutput(text: string): ProcessSnapshotRow[] {
return text
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
.flatMap((line) => {
const parts = line.split(WHITESPACE_PATTERN);
if (parts.length < 3) {
return [];
}
const pid = Number.parseInt(parts[0] ?? "", 10);
const ppid = Number.parseInt(parts[1] ?? "", 10);
const processGroupId = Number.parseInt(parts[2] ?? "", 10);
if (
!(
Number.isInteger(pid) &&
pid > 0 &&
Number.isInteger(ppid) &&
ppid >= 0 &&
Number.isInteger(processGroupId) &&
processGroupId > 0
)
) {
return [];
}
return [{ pid, ppid, processGroupId }];
});
}

export function collectDescendantProcessGroupIds(opts: {
readonly snapshot: readonly ProcessSnapshotRow[];
readonly rootPids: readonly number[];
}): number[] {
const processByParent = new Map<number, ProcessSnapshotRow[]>();
const groups = new Set<number>();
const queue = [...opts.rootPids];
const visited = new Set<number>();

for (const row of opts.snapshot) {
const siblings = processByParent.get(row.ppid) ?? [];
siblings.push(row);
processByParent.set(row.ppid, siblings);
}

while (queue.length > 0) {
const pid = queue.shift();
if (!(pid && pid > 0) || visited.has(pid)) {
continue;
}
visited.add(pid);

const current = opts.snapshot.find((row) => row.pid === pid);
if (current) {
groups.add(current.processGroupId);
}

for (const child of processByParent.get(pid) ?? []) {
groups.add(child.processGroupId);
if (!visited.has(child.pid)) {
queue.push(child.pid);
}
}
}

return [...groups].sort((left, right) => left - right);
}

export function resolveLifecycleProcessGroupIdsForTmuxState(opts: {
readonly lifecycleEntry: LifecycleStateEntry | null;
readonly panePidsByWindow: ReadonlyMap<string, readonly number[]>;
readonly snapshot: readonly ProcessSnapshotRow[];
}): number[] {
const rootPids = new Set<number>();

for (const processInfo of opts.lifecycleEntry?.processes ?? []) {
const currentPanePids =
opts.panePidsByWindow.get(processInfo.windowName) ?? [];
for (const panePid of currentPanePids) {
rootPids.add(panePid);
}
}

return collectDescendantProcessGroupIds({
snapshot: opts.snapshot,
rootPids: [...rootPids],
});
}

async function resolveLifecycleProcessGroupIds(opts: {
readonly sessionName: string;
readonly lifecycleEntry: LifecycleStateEntry | null;
}): Promise<number[]> {
const panePidsByWindow = new Map<string, readonly number[]>();
for (const processInfo of opts.lifecycleEntry?.processes ?? []) {
const panePids = await readTmuxPanePids({
sessionName: opts.sessionName,
windowName: processInfo.windowName,
});
panePidsByWindow.set(processInfo.windowName, panePids);
}

return resolveLifecycleProcessGroupIdsForTmuxState({
lifecycleEntry: opts.lifecycleEntry,
panePidsByWindow,
snapshot: await readProcessSnapshot(),
});
}

async function terminateLifecycleProcessGroups(opts: {
readonly processGroupIds: readonly number[];
}): Promise<void> {
const groups = [...new Set(opts.processGroupIds)].filter(
(processGroupId) => processGroupId > 1
);
if (groups.length === 0) {
return;
}

for (const processGroupId of groups) {
try {
process.kill(-processGroupId, "SIGTERM");
} catch {
// Ignore groups that already exited between snapshot and shutdown.
}
}

await Bun.sleep(500);

for (const processGroupId of groups) {
try {
process.kill(-processGroupId, 0);
} catch {
continue;
}
try {
process.kill(-processGroupId, "SIGKILL");
} catch {
// Ignore groups that exited after the SIGTERM grace period.
}
}
}

function resolveLifecycleCommandServiceName(opts: {
readonly command: ProjectLifecycleCommand;
readonly index: number;
Expand All @@ -1848,20 +2102,57 @@ function resolveLifecycleCommandServiceName(opts: {
return `hook-${opts.index + 1}`;
}

function wrapLifecyclePersistentCommand(opts: {
export function wrapLifecyclePersistentCommand(opts: {
readonly command: string;
readonly logPath: string;
readonly serviceName: string;
}): string {
const logPath = shellSingleQuote(opts.logPath);
const service = shellSingleQuote(opts.serviceName);
const command = shellSingleQuote(opts.command);
return [
`HACK_LIFECYCLE_LOG=${logPath}`,
`HACK_LIFECYCLE_SERVICE=${service}`,
`${opts.command} 2>&1 | while IFS= read -r line; do`,
" printf '%s\\n' \"$line\"",
' printf \'%s\\t%s\\tstdout\\t%s\\n\' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$HACK_LIFECYCLE_SERVICE" "$line" >> "$HACK_LIFECYCLE_LOG"',
"done",
`HACK_LIFECYCLE_COMMAND=${command}`,
`fifo="$(mktemp -u "\${TMPDIR:-/tmp}/hack-lifecycle.XXXXXX")"`,
'mkfifo "$fifo"',
"cleanup_lifecycle() {",
" trap - EXIT INT TERM HUP",
` if [ -n "\${cmd_pid:-}" ]; then`,
" if [ -x /bin/kill ]; then",
' /bin/kill -TERM -- "-$cmd_pid" 2>/dev/null || /bin/kill "$cmd_pid" 2>/dev/null || true',
" elif [ -x /usr/bin/kill ]; then",
' /usr/bin/kill -TERM -- "-$cmd_pid" 2>/dev/null || /usr/bin/kill "$cmd_pid" 2>/dev/null || true',
" else",
' kill "$cmd_pid" 2>/dev/null || true',
" fi",
' wait "$cmd_pid" 2>/dev/null || true',
" fi",
` if [ -n "\${reader_pid:-}" ]; then`,
' wait "$reader_pid" 2>/dev/null || true',
" fi",
' rm -f "$fifo"',
"}",
'trap "cleanup_lifecycle; exit 130" INT TERM HUP',
'trap "cleanup_lifecycle" EXIT',
"( while IFS= read -r line; do",
' printf "%s\\n" "$line"',
' printf \'%s\\t%s\\tstdout\\t%s\\n\' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$HACK_LIFECYCLE_SERVICE" "$line" >> "$HACK_LIFECYCLE_LOG"',
' done < "$fifo" ) &',
"reader_pid=$!",
"if command -v python3 >/dev/null 2>&1; then",
' python3 -c \'import os, sys; os.setsid(); os.execvp("sh", ["sh", "-lc", sys.argv[1]])\' "$HACK_LIFECYCLE_COMMAND" >"$fifo" 2>&1 &',
"else",
' sh -lc "$HACK_LIFECYCLE_COMMAND" >"$fifo" 2>&1 &',
Comment on lines +2144 to +2146

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Run lifecycle command without login-shell mode

The new wrapper executes lifecycle processes with sh -lc in both branches, which turns sh into a login shell and causes profile files to be sourced before the command runs. On Linux systems where /bin/sh is dash, non-POSIX content in ~/.profile can make the shell exit before the lifecycle command starts (I reproduced this with /bin/sh -lc 'echo ok' exiting with profile errors), so lifecycle hooks/tunnels fail to launch. The previous implementation used non-login execution and did not have this startup dependency.

Useful? React with 👍 / 👎.

"fi",
"cmd_pid=$!",
'wait "$cmd_pid"',
"cmd_status=$?",
'cmd_pid=""',
'wait "$reader_pid" 2>/dev/null || true',
'reader_pid=""',
'rm -f "$fifo"',
'exit "$cmd_status"',
].join("\n");
}

Expand Down
Loading
Loading