feat(envc): improve root help and add stdin hang repro script

Root usage documents commands; help/-h/--help and unknown-command paths print the same text. test_hang.sh reproduces blocking reads on stdin.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-03 10:22:35 +01:00
co-authored by Cursor
parent e3e8ccb6c9
commit 2d674de546
2 changed files with 94 additions and 2 deletions
+25 -2
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"io"
"os" "os"
) )
@@ -13,15 +14,36 @@ var (
date = "unknown" date = "unknown"
) )
// printUsage writes the root help text to w.
func printUsage(w io.Writer) {
_, _ = io.WriteString(w, `envc — convert, query, and merge configuration across env, YAML, JSON, and INI.
Usage:
envc <command> [arguments]
Commands:
convert Read one source, normalize keys, write another format (--from, --to)
get Read one source and print the value at a dot path (--from, --path)
merge Deep-merge multiple sources in order, then write one output (--from, --to)
version Print version and build metadata (also -v, --version)
Each command accepts its own flags; run:
envc <command> -h
`)
}
func main() { func main() {
stdin, stdout, stderr := os.Stdin, os.Stdout, os.Stderr stdin, stdout, stderr := os.Stdin, os.Stdout, os.Stderr
args := os.Args[1:] args := os.Args[1:]
if len(args) < 1 { if len(args) < 1 {
_, _ = fmt.Fprintln(stderr, "usage: envc <convert|get|merge|version> [flags]") printUsage(stderr)
os.Exit(2) os.Exit(2)
} }
var code int var code int
switch args[0] { switch args[0] {
case "help", "-h", "--help":
printUsage(stdout)
code = 0
case "convert": case "convert":
code = RunConvert(args[1:], stdin, stdout, stderr) code = RunConvert(args[1:], stdin, stdout, stderr)
case "get": case "get":
@@ -31,7 +53,8 @@ func main() {
case "version", "--version", "-v": case "version", "--version", "-v":
_, _ = fmt.Fprintf(stdout, "envc %s (commit %s, built %s)\n", version, commit, date) _, _ = fmt.Fprintf(stdout, "envc %s (commit %s, built %s)\n", version, commit, date)
default: default:
_, _ = fmt.Fprintf(stderr, "unknown command %q\n", args[0]) _, _ = fmt.Fprintf(stderr, "unknown command %q\n\n", args[0])
printUsage(stderr)
code = 2 code = 2
} }
os.Exit(code) os.Exit(code)
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Reproduce and explain envc "hangs": default input is stdin (-); io.ReadAll
# blocks until EOF. A TTY waits for Ctrl-D; /dev/zero never EOF (infinite read).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
ENVC="${TMPDIR:-/tmp}/envc-hang-test.$$"
cleanup() { rm -f "$ENVC"; }
trap cleanup EXIT
echo "== build envc -> $ENVC"
go build -o "$ENVC" ./cmd/envc
echo
echo "== 1) expected to finish quickly (YAML on stdin, explicit -)"
printf 'k: 1\n' | "$ENVC" convert --from yaml --to json --input - --output - | head -c 200
echo
echo " (ok)"
echo
echo "== 2) empty stdin should finish quickly (EOF immediately)"
if ! out="$( "$ENVC" convert --from yaml --to json --input - --output - </dev/null 2>&1 )"; then
echo " exit non-zero (often empty YAML parse); stderr/out:"
echo "$out" | sed 's/^/ | /'
echo " (ok — no hang, process terminated)"
else
echo "$out" | sed 's/^/ /'
echo " (ok — no hang)"
fi
echo
echo "== 3) infinite stdin: should hit wall-clock limit (GNU timeout -> 124)"
if ! command -v timeout >/dev/null; then
echo " skip: no 'timeout' in PATH"
else
set +e
timeout -v 2s "$ENVC" convert --from yaml --to json --input - --output - </dev/zero >/dev/null 2>&1
ec=$?
set -e
if [[ "$ec" -eq 124 ]]; then
echo " timeout fired (exit 124) — binary was blocked in read (no EOF on stdin)."
elif [[ "$ec" -eq 137 ]] || [[ "$ec" -eq 143 ]]; then
echo " killed by signal (exit $ec) — still indicates blocked read until SIGKILL/SIGTERM."
else
echo " unexpected exit $ec (expected 124 from GNU timeout on hang)"
exit 1
fi
fi
echo
echo "== why it happens"
cat <<'EOF'
convert/get default to reading the input source once with io.ReadAll.
For --input - (convert) or positional "-" (get), that is os.Stdin until EOF.
- Interactive shell, no pipe: stdin is a TTY — blocked until you press Ctrl-D
or pipe data in.
- /dev/zero (or any endless reader): never EOF — blocked until killed or OOM.
merge uses the same pattern for non-"-" inputs via bytesutil; URL fetches use
a 30s HTTP client timeout (see internal/source.URL).
Fix for users: pass a file or URL (--input path), or pipe finite data into stdin.
EOF
echo
echo "== done"