shout/src/run.ts

329 lines
8.8 KiB
TypeScript

import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { Command, ShoutFile } from "./parse.ts"
export type CommandResult = {
command: Command
actual: string[]
exitCode: number
}
export type FileResult = {
file: ShoutFile
results: CommandResult[]
tmpDir: string
error?: string
}
type RunOptions = {
cleanEnv: boolean
pathDirs?: string[]
envVars?: Record<string, string>
sourceDir?: string
projectDir?: string
timeout: number
verbose: boolean
onCommand?: (cmd: Command) => void
}
function killTree(pid: number): void {
// Find any processes that escaped the process group (e.g. via setsid).
// This assumes pid === pgid, which holds because the child is spawned
// with detached: true (making it a process group leader).
try {
const result = Bun.spawnSync(["ps", "-eo", "pid,pgid"])
const output = result.stdout.toString()
const pgid = String(pid)
for (const line of output.split("\n")) {
const parts = line.trim().split(/\s+/)
if (parts[1] === pgid) {
const p = parseInt(parts[0]!, 10)
if (!isNaN(p) && p !== pid && p > 1) {
try { process.kill(p, "SIGKILL") } catch {}
}
}
}
} catch {}
// Kill the process group
try { process.kill(-pid, "SIGKILL") } catch {}
}
const SENTINEL_PREFIX = "__SHOUT_SENTINEL_"
const VERBOSE_MARKER = "__SHOUT_CMD_"
function buildScript(commands: Command[], sentinel: string, verbose: boolean): string {
const lines: string[] = []
if (verbose) {
// Save original stderr to fd 3 before merging stderr into stdout
lines.push("exec 3>&2 2>&1")
} else {
lines.push("exec 2>&1")
}
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i]!
if (verbose) {
lines.push(`printf '${VERBOSE_MARKER}${i}\\n' >&3`)
}
lines.push(cmd.command)
// Sentinel: printf to avoid echo interpretation issues
// Format: __SHOUT_SENTINEL_<exitcode>_<index>__
lines.push(
`printf '\\n${sentinel}%s_${i}__\\n' "$?"`,
)
}
return lines.join("\n") + "\n"
}
function parseSentinelOutput(
raw: string,
sentinel: string,
commandCount: number,
): { outputs: string[][]; exitCodes: number[] } {
const outputs: string[][] = []
const exitCodes: number[] = []
// Split by sentinel lines
const sentinelRegex = new RegExp(
`${escapeRegex(sentinel)}(\\d+)_(\\d+)__`,
)
let remaining = raw
for (let i = 0; i < commandCount; i++) {
const match = remaining.match(sentinelRegex)
if (!match) {
// No sentinel found — rest is output for this command
const lines = remaining.split("\n")
// Remove leading empty line (from printf \n prefix)
if (lines.length > 0 && lines[0] === "") lines.shift()
outputs.push(trimTrailingEmpty(lines))
exitCodes.push(1) // assume failure
break
}
const idx = remaining.indexOf(match[0])
const before = remaining.slice(0, idx)
const afterSentinel = remaining.slice(idx + match[0].length)
// Parse output lines
let lines = before.split("\n")
// Remove leading empty line from previous sentinel's trailing \n
if (lines.length > 0 && lines[0] === "") lines.shift()
// Remove trailing empty lines (from printf's \n prefix)
lines = trimTrailingEmpty(lines)
outputs.push(lines.length === 1 && lines[0] === "" ? [] : lines)
exitCodes.push(parseInt(match[1]!, 10))
// Skip past sentinel line (including trailing newline)
remaining = afterSentinel.startsWith("\n")
? afterSentinel.slice(1)
: afterSentinel
}
// Fill missing entries
while (outputs.length < commandCount) {
outputs.push([])
exitCodes.push(1)
}
return { outputs, exitCodes }
}
function trimTrailingEmpty(lines: string[]): string[] {
let end = lines.length
while (end > 0 && lines[end - 1] === "") end--
return lines.slice(0, end)
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
function streamVerboseMarkers(
stderr: ReadableStream<Uint8Array>,
commands: Command[],
onCommand: (cmd: Command) => void,
): void {
const reader = stderr.getReader()
const decoder = new TextDecoder()
let buffer = ""
const pump = (): void => {
reader.read().then(({ done, value }) => {
if (done) return
if (value) buffer += decoder.decode(value, { stream: true })
let nlIdx: number
while ((nlIdx = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nlIdx)
buffer = buffer.slice(nlIdx + 1)
if (line.startsWith(VERBOSE_MARKER)) {
const i = parseInt(line.slice(VERBOSE_MARKER.length), 10)
if (i >= 0 && i < commands.length) {
onCommand(commands[i]!)
}
}
}
pump()
}).catch(() => {})
}
pump()
}
export async function runFile(
file: ShoutFile,
options: RunOptions,
): Promise<FileResult> {
const tmpDir = await mkdtemp(join(tmpdir(), "shout-"))
if (file.commands.length === 0) {
return { file, results: [], tmpDir }
}
const sentinel = SENTINEL_PREFIX
const verbose = options.verbose && !!options.onCommand
const script = buildScript(file.commands, sentinel, verbose)
const env: Record<string, string> = options.cleanEnv
? {}
: { ...process.env as Record<string, string> }
env["HOME"] = tmpDir
env["SHOUT_DIR"] = tmpDir
if (options.sourceDir) {
env["SHOUT_SOURCE_DIR"] = options.sourceDir
}
if (options.projectDir) {
env["SHOUT_PROJECT_DIR"] = options.projectDir
}
if (options.envVars) {
Object.assign(env, options.envVars)
}
if (options.pathDirs?.length) {
env["PATH"] = options.pathDirs.join(":") + ":" + (env["PATH"] ?? "")
}
const proc = Bun.spawn(["/bin/sh"], {
detached: true,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
cwd: tmpDir,
env,
})
try {
if (verbose) {
// Stream stderr for verbose command markers before writing script
streamVerboseMarkers(proc.stderr, file.commands, options.onCommand!)
}
proc.stdin.write(script)
proc.stdin.end()
const totalTimeout = options.timeout * file.commands.length
const lastSentinelSuffix = `_${file.commands.length - 1}__`
const stdout = await readUntilSentinel(proc.stdout, sentinel, lastSentinelSuffix, totalTimeout)
if (!verbose) {
await readWithTimeout(proc.stderr, 1000).catch(() => "")
}
const { outputs, exitCodes } = parseSentinelOutput(
stdout,
sentinel,
file.commands.length,
)
const results: CommandResult[] = file.commands.map((cmd, i) => ({
command: cmd,
actual: outputs[i] ?? [],
exitCode: exitCodes[i] ?? 1,
}))
return { file, results, tmpDir }
} catch (err) {
return {
file,
results: [],
tmpDir,
error: err instanceof Error ? err.message : String(err),
}
} finally {
if (proc.pid) {
killTree(proc.pid)
}
}
}
async function readUntilSentinel(
stream: ReadableStream<Uint8Array>,
sentinelPrefix: string,
sentinelSuffix: string,
timeoutMs: number,
): Promise<string> {
const reader = stream.getReader()
const decoder = new TextDecoder()
let accumulated = ""
let timerId: ReturnType<typeof setTimeout>
const timeout = new Promise<never>((_, reject) =>
timerId = setTimeout(() => reject(new Error("Timeout reading output")), timeoutMs),
)
try {
while (true) {
const { done, value } = await Promise.race([reader.read(), timeout]) as ReadableStreamReadResult<Uint8Array>
if (done) break
if (value) {
accumulated += decoder.decode(value, { stream: true })
// Check if the last sentinel has appeared (prefix + exitcode + suffix)
const prefixIdx = accumulated.lastIndexOf(sentinelPrefix)
if (prefixIdx !== -1 && accumulated.indexOf(sentinelSuffix, prefixIdx) !== -1) break
}
}
} finally {
clearTimeout(timerId!)
reader.releaseLock()
}
return accumulated + decoder.decode()
}
async function readWithTimeout(
stream: ReadableStream<Uint8Array>,
timeoutMs: number,
): Promise<string> {
const reader = stream.getReader()
const chunks: Uint8Array[] = []
let timerId: ReturnType<typeof setTimeout>
const timeout = new Promise<never>((_, reject) =>
timerId = setTimeout(() => reject(new Error("Timeout reading output")), timeoutMs),
)
try {
while (true) {
const { done, value } = await Promise.race([reader.read(), timeout]) as ReadableStreamReadResult<Uint8Array>
if (done) break
if (value) chunks.push(value)
}
} finally {
clearTimeout(timerId!)
reader.releaseLock()
}
const decoder = new TextDecoder()
return chunks.map(c => decoder.decode(c, { stream: true })).join("") +
decoder.decode()
}
export async function cleanupTmpDir(dir: string): Promise<void> {
await rm(dir, { recursive: true, force: true })
}