diff --git a/disag.js b/disag.js index 2050613..721eb7c 100755 --- a/disag.js +++ b/disag.js @@ -4,7 +4,8 @@ import fs from "node:fs"; import { spawn } from "node:child_process"; import process from "node:process"; -const colors = [31, 32, 33, 34, 35, 36, 91, 92, 93, 94, 95, 96]; +const systemColor = "31"; +const commandColors = ["32", "33", "34", "35", "36", "92", "93", "94", "95", "96"]; const signalCodes = { SIGINT: 130, SIGTERM: 143, @@ -14,14 +15,14 @@ const signalCodes = { function usage() { process.stderr.write( - "usage: diag [-z|--no-color] command [-f|--file file] [-n|--name name] [-s|--shell] ...\n", + "usage: diag [-z|--no-color] command [-f|--file file] [-n|--name name] [-r|--retry] [-s|--shell] ...\n", ); process.stderr.write("try: diag --help\n"); process.exit(1); } function help() { - process.stdout.write(`usage: diag [-z|--no-color] command [-f|--file file] [-n|--name name] [-s|--shell] ... + process.stdout.write(`usage: diag [-z|--no-color] command [-f|--file file] [-n|--name name] [-r|--retry] [-s|--shell] ... diag -h|--help Run one or more commands at the same time and mirror their output. @@ -36,16 +37,19 @@ Options: to file; if repeated for that command, the last value wins -n, --name name prefix displayed lines for the immediately preceding command with [name]; not written to log files + -r, --retry retry the immediately preceding command until it exits + with code 0 -s, --shell execute the immediately preceding command with sh -c -z, --no-color disable ANSI colors even when stdout is a terminal -h, --help show this help text Notes: - -f, -n, and -s always apply to the command immediately before them. + -f, -n, -r, and -s always apply to the command immediately before them. If no -f is specified for a command, its output is only displayed. Display prefixes and ANSI colors are never written to log files. Colors are used only when stdout is a terminal. Each command prints a [DISAG] exit-code line to stderr when it exits. + Retried commands print a [DISAG] retry line before each retry. diag-js only writes paths named with -f. Signals are forwarded to child processes with kill. @@ -56,6 +60,8 @@ Examples: diag 'echo out; echo err >&2' -s -f ./command.log -n CMD + diag 'flaky_command' -r -n FLAKY + diag \\ 'while true; do echo hello; sleep 1; done' -s -f ./hello.log -n HELLO \\ 'some_command' -f ./some_command.log @@ -110,8 +116,15 @@ function parseArgs(args) { current.shell = true; continue; } + if (arg === "-r" || arg === "--retry") { + if (current === null) { + usage(); + } + current.retry = true; + continue; + } finishCurrent(); - current = { command: arg, file: "", name: "", shell: false }; + current = { command: arg, file: "", name: "", retry: false, shell: false }; } finishCurrent(); @@ -190,17 +203,24 @@ function decorate(line, spec) { return out; } -function colorize(line, spec) { - if (spec.color === "") { +function colorize(line, color) { + if (color === "") { return line; } - return `\x1b[${spec.color}m${line}\x1b[0m`; + return `\x1b[${color}m${line}\x1b[0m`; } function reportExit(spec, status) { const label = spec.name !== "" ? spec.name : spec.command; process.stderr.write( - `${colorize(`[DISAG] ${label} exited with exit code ${status}`, spec)}\n`, + `${colorize(`[DISAG] ${label} exited with exit code ${status}`, spec.systemColor)}\n`, + ); +} + +function reportRetry(spec, status) { + const label = spec.name !== "" ? spec.name : spec.command; + process.stderr.write( + `${colorize(`[DISAG] ${label} retrying after exit code ${status}`, spec.systemColor)}\n`, ); } @@ -240,6 +260,9 @@ function attachLineStream(readable, display, log, spec) { } function killChild(child, signal) { + if (child === null) { + return; + } if (child.exitCode !== null || child.signalCode !== null) { return; } @@ -257,65 +280,77 @@ function killChild(child, signal) { function runCommand(spec) { const argv = spec.shell ? ["sh", "-c", spec.command] : splitCommand(spec.command); - let log = null; - if (spec.file !== "") { - log = fs.openSync(spec.file, "w"); - } + const run = { child: null, done: null }; - const child = spawn(argv[0], argv.slice(1), { - detached: true, - stdio: ["ignore", "pipe", "pipe"], - }); + run.done = (async () => { + for (;;) { + let log = null; + if (spec.file !== "") { + log = fs.openSync(spec.file, "w"); + } - if (child.stdout !== null) { - attachLineStream(child.stdout, process.stdout, log, spec); - } - if (child.stderr !== null) { - attachLineStream(child.stderr, process.stderr, log, spec); - } + const child = spawn(argv[0], argv.slice(1), { + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); + run.child = child; - const done = new Promise((resolve) => { - child.on("error", (err) => { - if (log !== null) { - fs.closeSync(log); + if (child.stdout !== null) { + attachLineStream(child.stdout, process.stdout, log, spec); } - process.stderr.write(`diag-js: ${err.message}\n`); - resolve(1); - }); - - child.on("close", (code, signal) => { - if (log !== null) { - fs.closeSync(log); + if (child.stderr !== null) { + attachLineStream(child.stderr, process.stderr, log, spec); } - if (code !== null) { - reportExit(spec, code); - resolve(code); - } else if (signal !== null && signal in signalCodes) { - const status = signalCodes[signal]; - reportExit(spec, status); - resolve(status); - } else { - reportExit(spec, 1); - resolve(1); + + const status = await new Promise((resolve) => { + child.on("error", (err) => { + if (log !== null) { + fs.closeSync(log); + } + process.stderr.write(`diag-js: ${err.message}\n`); + resolve(1); + }); + + child.on("close", (code, signal) => { + if (log !== null) { + fs.closeSync(log); + } + if (code !== null) { + resolve(code); + } else if (signal !== null && signal in signalCodes) { + resolve(signalCodes[signal]); + } else { + resolve(1); + } + }); + }); + + run.child = null; + reportExit(spec, status); + if (!spec.retry || status === 0 || spec.stopping()) { + return status; } - }); - }); + reportRetry(spec, status); + } + })(); - return { child, done }; + return run; } async function main() { const { commands, noColor } = parseArgs(process.argv.slice(2)); const useColor = !noColor && process.stdout.isTTY; + let stopping = false; + let stopCode = null; const runs = commands.map((command, index) => runCommand({ ...command, - color: useColor ? String(colors[index % colors.length]) : "", + color: useColor ? commandColors[index % commandColors.length] : "", + systemColor: useColor ? systemColor : "", + stopping: () => stopping, }), ); - let stopping = false; - let stopCode = null; for (const signal of Object.keys(signalCodes)) { process.on(signal, () => { if (stopping) { diff --git a/tests/disag-js.rc b/tests/disag-js.rc index 1c239cb..b6f45a3 100755 --- a/tests/disag-js.rc +++ b/tests/disag-js.rc @@ -83,11 +83,35 @@ printf '%s\n' '[DISAG] echo command-label exited with exit code 0' > $tmp/comman assert_file $tmp/command-label.out $tmp/command-label.out.want command-label-stdout assert_file $tmp/command-label.err $tmp/command-label.err.want command-label-stderr +script -q $tmp/color.tty $diag 'echo color-test' -n COLOR >/dev/null || fail color-run +awk 'BEGIN { ok = 0; pat = sprintf("%c[31m[DISAG]", 27) } index($0, pat) { ok = 1 } END { exit ok ? 0 : 1 }' $tmp/color.tty || fail color-system-red +awk 'BEGIN { ok = 0; pat = sprintf("%c[32m[COLOR]", 27) } index($0, pat) { ok = 1 } END { exit ok ? 0 : 1 }' $tmp/color.tty || fail color-command-non-red +awk 'BEGIN { bad = 0; pat = sprintf("%c[31m[COLOR]", 27) } index($0, pat) { bad = 1 } END { exit bad ? 1 : 0 }' $tmp/color.tty || fail color-command-red + +script -q $tmp/color-rotation.tty $diag 'echo one' -n C1 'echo two' -n C2 'echo three' -n C3 'echo four' -n C4 'echo five' -n C5 'echo six' -n C6 >/dev/null || fail color-rotation-run +awk 'BEGIN { bad = 0; red = sprintf("%c[31m[C", 27); brightred = sprintf("%c[91m[C", 27) } index($0, red) || index($0, brightred) { bad = 1 } END { exit bad ? 1 : 0 }' $tmp/color-rotation.tty || fail color-rotation-red-command + +script -q $tmp/no-color.tty $diag -z 'echo color-test' -n COLOR >/dev/null || fail no-color-run +awk 'BEGIN { bad = 0; pat = sprintf("%c[", 27) } index($0, pat) { bad = 1 } END { exit bad ? 1 : 0 }' $tmp/no-color.tty || fail no-color-has-ansi + $diag 'node tests/fixtures/diag-helper.js exit 7' > $tmp/status.out >[2=1] s=$status if(~ $s 0) fail status-zero +$diag 'node tests/fixtures/diag-helper.js flaky '^$tmp^'/retry.count' -r -n RETRY -f $tmp/retry.log > $tmp/retry.out >[2]$tmp/retry.err || fail retry-run +printf '%s\n' '[RETRY] attempt 1' '[RETRY] attempt 2' > $tmp/retry.out.want +printf '%s\n' '[DISAG] RETRY exited with exit code 7' '[DISAG] RETRY retrying after exit code 7' '[DISAG] RETRY exited with exit code 0' > $tmp/retry.err.want +printf '%s\n' 'attempt 2' > $tmp/retry.log.want +printf '%s\n' 2 > $tmp/retry.count.want +assert_file $tmp/retry.out $tmp/retry.out.want retry-stdout +assert_file $tmp/retry.err $tmp/retry.err.want retry-stderr +assert_file $tmp/retry.log $tmp/retry.log.want retry-log-last-attempt +assert_file $tmp/retry.count $tmp/retry.count.want retry-count + +script -q $tmp/retry-color.tty $diag 'node tests/fixtures/diag-helper.js flaky '^$tmp^'/retry-color.count' -r -n RETRYCOLOR >/dev/null || fail retry-color-run +awk 'BEGIN { ok = 0; pat = sprintf("%c[31m[DISAG] RETRYCOLOR retrying after exit code 7", 27) } index($0, pat) { ok = 1 } END { exit ok ? 0 : 1 }' $tmp/retry-color.tty || fail retry-color-red + sigcmd='node tests/fixtures/diag-helper.js signal '^$tmp $diag $sigcmd -f $tmp/signal.log > $tmp/signal.out >[2=1] & pid=$apid diff --git a/tests/fixtures/diag-helper.js b/tests/fixtures/diag-helper.js index 5ac8e7f..964f973 100644 --- a/tests/fixtures/diag-helper.js +++ b/tests/fixtures/diag-helper.js @@ -9,6 +9,18 @@ if (mode === "emit") { process.stderr.write(`${args[1]}\n`); } else if (mode === "exit") { process.exit(Number(args[0])); +} else if (mode === "flaky") { + const counter = args[0]; + let attempts = 0; + try { + attempts = Number(fs.readFileSync(counter, "utf8")); + } catch { + attempts = 0; + } + attempts += 1; + fs.writeFileSync(counter, `${attempts}\n`); + process.stdout.write(`attempt ${attempts}\n`); + process.exit(attempts < 2 ? 7 : 0); } else if (mode === "signal") { const dir = args[0]; process.on("SIGTERM", () => {