Files
Zecheng Zhang 4277f017ba perf(integ): run the two CLI hosts concurrently
integ-runtime is the longest job in the Integ workflow, and one step is
essentially all of it: "Run every runtime through the CLI and both
daemons" was 27 minutes of a 30 minute job, while the two JSON-suite
steps beside it took 16 and 32 seconds. cli.sh spent that running the
python host to completion and then the typescript host, though the two
share nothing that matters: separate daemon ports, a MIRAGE_HOME each,
and only ram mounts reach the CLI at all, so no store is common to them.
The docker suite is the one thing they do share, and its six cases are
stateless execs (echo, exit, wc, uname) rather than writes, so two
`docker exec` sessions in the one container cannot collide.

So both hosts now run at once. A host's tally has to leave its subshell
through the filesystem, and the parent sums the two, keeps a missing
tally as a failure rather than a zero, and prints each host's log whole
instead of letting them interleave.

Measured locally against the same suites: 465s sequential, 273s
concurrent, both reporting 171 passed, 0 failed, 37 skipped. Pointing one
host at a CLI that does not exist still exits 1 and still attributes
every failure line to that host.
2026-08-17 01:46:35 -07:00

348 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# Drive the runtime JSON suites through the CLI and daemon of both
# languages: the same cases integ/runtime/run.{py,ts} execute in
# process, here built from a generated workspace yaml (`mirage
# workspace create`) and executed with `mirage execute`. This is the
# yaml -> daemon -> CLI construction path: entry captures, config
# blocks, per-entry scripts (policy), the global route, per-mount
# command_limits, and the per-line --runtime argument.
#
# Cases whose steps need the SDK surface (add_runtime, rename, s3_put,
# read_op, facade — the last calls ws.ops directly) or a runner-local
# test runtime (echobox) or runner-local code policies (world.policies)
# or non-ram mounts are skipped as sdk-only. Expect semantics: exit and
# stdout are exact, stderr is a containment check (the CLI owns its
# stderr framing), and the SDK-side expectations (ops_contain,
# ops_absent, value) are not checked because the op ledger has no CLI
# door.
#
# A yaml file is any JSON document here: YAML is a superset of JSON,
# so the driver emits the case world as JSON with jq and both loaders
# parse it; inline script sources become .py files next to the yaml.
#
# Usage: cli.sh "<py-cli>" "<ts-cli>"
set -uo pipefail
PY_CLI="${1:?python mirage cli command}"
TS_CLI="${2:?typescript mirage cli command}"
SUITE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STRICT="${INTEG_RUNTIME_STRICT:-0}"
pass=0
fail=0
skipped=0
failures=()
requirement_met() {
local req="$1"
local var
case "$req" in
env:*)
var="${req#env:}"
[ -n "${!var:-}" ] ;;
s3) return 1 ;;
*) echo "unknown requirement: $req" >&2; return 1 ;;
esac
}
# Whether this case can run over the CLI at all. Worlds carrying code
# policies (runner-local Policy classes) cannot cross the yaml/daemon
# boundary, and read_op steps need the SDK op door.
cli_expressible() {
local case_json="$1"
jq -e '
((.world.mounts // {"/ram": {"resource": "ram"}})
| to_entries | all(.value.resource == "ram"))
and (((.world.policies // []) | length) == 0)
and (((.world.runtimes // []) | map(select(type == "object" and .name == "echobox")) | length) == 0)
and (((.steps // []) | map(select(has("add_runtime") or has("rename") or has("s3_put") or has("read_op") or has("facade"))) | length) == 0)
' >/dev/null <<<"$case_json"
}
# Emit the workspace yaml (as JSON) for a case world, extracting any
# inline script sources into .py files under $2.
write_world_yaml() {
local world_json="$1" work="$2"
local n i src policy
n=$(jq '(.runtimes // []) | length' <<<"$world_json")
for i in $(seq 0 $((n - 1))); do
src=$(jq -r ".runtimes[$i] | if type == \"object\" then .script // empty else empty end" <<<"$world_json")
if [ -n "$src" ]; then
printf '%s' "$src" > "$work/script_$i.py"
world_json=$(jq --arg p "$work/script_$i.py" ".runtimes[$i].script = \$p" <<<"$world_json")
fi
done
policy=$(jq -r '.policy // empty' <<<"$world_json")
if [ -n "$policy" ]; then
printf '%s' "$policy" > "$work/policy.py"
world_json=$(jq --arg p "$work/policy.py" '.policy = $p' <<<"$world_json")
fi
# A script CLI's program becomes a file the yaml `clis:` block points
# at, the same build-context shape a deployment writes by hand; the
# extension carries the language the runner passed inline.
local cli ext
while IFS= read -r cli; do
[ -n "$cli" ] || continue
src=$(jq -r --arg n "$cli" '.clis[$n].script' <<<"$world_json")
ext=$([ "$(jq -r --arg n "$cli" '.clis[$n].language // "python"' <<<"$world_json")" = "js" ] && echo js || echo py)
printf '%s' "$src" > "$work/cli_$cli.$ext"
world_json=$(jq --arg n "$cli" --arg p "$work/cli_$cli.$ext" \
'.clis[$n] = ((.clis[$n] | del(.script, .language)) + {script: $p})' \
<<<"$world_json")
done < <(jq -r '(.clis // {}) | keys[]' <<<"$world_json")
jq '{mode: "EXEC",
mounts: ((.mounts // {"/ram": {"resource": "ram"}})
| map_values({resource: .resource}
+ (if .limits then {command_limits: .limits} else {} end)))}
+ (if .runtimes then {runtimes: .runtimes} else {} end)
+ (if .policy then {policy: .policy} else {} end)
+ (if .clis then {clis: .clis} else {} end)' \
<<<"$world_json" > "$work/ws.yaml"
}
run_case() {
local cli="$1" host="$2" suite="$3" case_json="$4" work="$5"
local case_id wsid world_json
case_id="$suite/$(jq -r '.id' <<<"$case_json")"
wsid="rt-$(jq -r '.id' <<<"$case_json" | tr '_' '-')"
world_json=$(jq -c '.world // {}' <<<"$case_json")
write_world_yaml "$world_json" "$work"
if jq -e 'has("build_error")' >/dev/null <<<"$case_json"; then
local want
want=$(jq -r '.build_error.contains' <<<"$case_json")
if $cli workspace create "$work/ws.yaml" --id "$wsid" \
>"$work/create.out" 2>&1 </dev/null; then
failures+=("$case_id: expected workspace create to fail")
$cli workspace delete "$wsid" >/dev/null 2>&1 </dev/null || true
return 1
fi
if ! grep -qF "$want" "$work/create.out"; then
failures+=("$case_id: create error missing '$want': $(head -c 300 "$work/create.out")")
return 1
fi
return 0
fi
if ! $cli workspace create "$work/ws.yaml" --id "$wsid" \
>"$work/create.out" 2>&1 </dev/null; then
failures+=("$case_id: workspace create failed: $(head -c 300 "$work/create.out")")
return 1
fi
# Seed declared mount files through the shell (cat reads the piped
# stdin, the redirect writes the mount). A nested seed name needs its
# parent first: the redirect refuses a missing directory, and it
# refuses silently here, which reads as a file that was never
# declared (run.py and run.ts mkdir the parent the same way).
local prefix name ok=0
while IFS=$'\t' read -r prefix name; do
[ -n "$prefix" ] || continue
case "$name" in
*/*)
$cli execute -w "$wsid" -c "mkdir -p $prefix/${name%/*}" \
>/dev/null 2>&1 </dev/null
;;
esac
jq -j --arg p "$prefix" --arg n "$name" \
'.world.mounts[$p].files[$n]' <<<"$case_json" \
| $cli execute -w "$wsid" -c "cat > $prefix/$name" >/dev/null 2>&1
done < <(jq -r '(.world.mounts // {}) | to_entries[]
| .key as $p | (.value.files // {}) | keys[]
| [$p, .] | @tsv' <<<"$case_json")
local steps step cmd runtime expect got_exit
steps=$(jq -c '.steps[]' <<<"$case_json")
local index=0
while IFS= read -r step; do
cmd=$(jq -r '.command' <<<"$step")
runtime=$(jq -r '.runtime // empty' <<<"$step")
expect=$(jq -c '.expect // {}' <<<"$step")
local args=(execute -w "$wsid" -c "$cmd")
[ -n "$runtime" ] && args+=(--runtime "$runtime")
if jq -e 'has("stdin")' >/dev/null <<<"$step"; then
jq -j '.stdin' <<<"$step" > "$work/stdin.bin"
else
: > "$work/stdin.bin"
fi
$cli "${args[@]}" < "$work/stdin.bin" \
> "$work/got.out" 2> "$work/got.err"
got_exit=$?
# Both CLIs emit a JSON envelope on a non-tty stdout; unwrap the
# command's own streams from it (raw output stays the fallback
# for CLI-level errors).
if jq -e '.kind == "io"' "$work/got.out" >/dev/null 2>&1; then
jq -j '.stdout // ""' "$work/got.out" > "$work/got.stdout"
jq -j '.stderr // ""' "$work/got.out" > "$work/got.stderr"
else
cp "$work/got.out" "$work/got.stdout"
cp "$work/got.err" "$work/got.stderr"
fi
if jq -e 'has("throws_contains")' >/dev/null <<<"$expect"; then
local want
want=$(jq -r '.throws_contains' <<<"$expect")
if [ "$got_exit" -eq 0 ] || ! grep -qF "$want" "$work/got.out" "$work/got.err"; then
failures+=("$case_id step[$index]: expected an error containing '$want'")
ok=1
fi
index=$((index + 1))
continue
fi
if jq -e 'has("exit")' >/dev/null <<<"$expect"; then
local want_exit
want_exit=$(jq -r '.exit' <<<"$expect")
if [ "$got_exit" -ne "$want_exit" ]; then
failures+=("$case_id step[$index]: exit $got_exit, expected $want_exit: $(head -c 200 "$work/got.stderr")")
ok=1
fi
fi
if jq -e 'has("stdout")' >/dev/null <<<"$expect"; then
jq -j '.stdout' <<<"$expect" > "$work/want.out"
if ! cmp -s "$work/want.out" "$work/got.stdout"; then
failures+=("$case_id step[$index]: stdout '$(cat "$work/got.stdout")', expected '$(cat "$work/want.out")'")
ok=1
fi
fi
if jq -e 'has("stdout_contains")' >/dev/null <<<"$expect"; then
local want_frag
want_frag=$(jq -r '.stdout_contains' <<<"$expect")
if ! grep -qF "$want_frag" "$work/got.stdout"; then
failures+=("$case_id step[$index]: stdout missing '$want_frag'")
ok=1
fi
fi
# The CLI owns its stderr framing, so exact stderr expectations
# degrade to containment here.
local want_err
for key in stderr stderr_contains; do
if jq -e --arg k "$key" 'has($k)' >/dev/null <<<"$expect"; then
want_err=$(jq -r --arg k "$key" '.[$k]' <<<"$expect")
if [ -n "$want_err" ] && ! grep -qF "$want_err" "$work/got.stderr"; then
failures+=("$case_id step[$index]: stderr missing '$want_err': $(head -c 200 "$work/got.stderr")")
ok=1
fi
fi
done
index=$((index + 1))
done <<<"$steps"
$cli workspace delete "$wsid" >/dev/null 2>&1 </dev/null || true
return $ok
}
run_host() {
local cli="$1" host="$2" port="$3"
local home work
home="$(mktemp -d "/tmp/rt-cli-$host-home.XXXXXX")"
work="$(mktemp -d "/tmp/rt-cli-$host-work.XXXXXX")"
export MIRAGE_HOME="$home"
unset MIRAGE_DAEMON_PORT MIRAGE_DAEMON_URL MIRAGE_ALLOWED_HOSTS \
MIRAGE_AUTH_MODE 2>/dev/null || true
$cli config set port "$port" >/dev/null </dev/null
$cli config set url "http://127.0.0.1:$port" >/dev/null </dev/null
local file suite suite_json requires unmet
for file in "$SUITE_DIR"/*.json; do
suite_json=$(cat "$file")
suite=$(jq -r '.suite' <<<"$suite_json")
requires=$(jq -r --arg h "$host" \
'(.requires // []) | if type == "array" then . else (.[$h] // []) end | .[]' \
<<<"$suite_json")
unmet=""
for req in $requires; do
requirement_met "$req" || unmet="$unmet $req"
done
if [ -n "$unmet" ]; then
if [ "$STRICT" == "1" ] && \
[ "$(jq -r '.optional // false' <<<"$suite_json")" != "true" ]; then
failures+=("$host/$suite: unmet requirements$unmet (INTEG_RUNTIME_STRICT=1)")
fail=$((fail + 1))
else
echo "skip $host/$suite (unmet:$unmet)"
skipped=$((skipped + 1))
fi
continue
fi
local case_json
while IFS= read -r case_json; do
if ! jq -e --arg h "$host" \
'(.hosts // ["python", "typescript"]) | index($h)' \
>/dev/null <<<"$case_json"; then
continue
fi
if ! cli_expressible "$case_json"; then
echo "skip $host/$suite/$(jq -r '.id' <<<"$case_json") (sdk-only)"
skipped=$((skipped + 1))
continue
fi
if run_case "$cli" "$host" "$suite" "$case_json" "$work"; then
echo "ok $host/$suite/$(jq -r '.id' <<<"$case_json")"
pass=$((pass + 1))
else
echo "FAIL $host/$suite/$(jq -r '.id' <<<"$case_json")"
fail=$((fail + 1))
fi
done < <(jq -c '.cases[]' <<<"$suite_json")
done
$cli daemon stop >/dev/null 2>&1 </dev/null || true
sleep 1
# Each host runs in its own subshell, so its tally has to leave through
# the filesystem: a subshell's variables die with it.
printf '%s %s %s\n' "$pass" "$fail" "$skipped" > "$RESULT_DIR/$host.tally"
: > "$RESULT_DIR/$host.failures"
for line in "${failures[@]:-}"; do
[ -n "$line" ] && printf '%s\n' "$line" >> "$RESULT_DIR/$host.failures"
done
}
# The two hosts are independent: separate daemon ports, a MIRAGE_HOME each,
# and only ram mounts reach the CLI (see cli_expressible), so they share no
# store. The docker suite is the one thing they do share, and its cases are
# stateless execs (echo, exit, wc, uname) rather than writes, so two
# `docker exec` sessions in the one container cannot collide. Running them
# together halves the longest step in the integ workflow.
RESULT_DIR="$(mktemp -d "/tmp/rt-cli-results.XXXXXX")"
(run_host "$PY_CLI" "python" 8791) > "$RESULT_DIR/python.log" 2>&1 &
py_pid=$!
(run_host "$TS_CLI" "typescript" 8792) > "$RESULT_DIR/typescript.log" 2>&1 &
ts_pid=$!
wait "$py_pid"
wait "$ts_pid"
# Printed per host rather than interleaved, which is what makes a failure
# readable: the two hosts would otherwise write over each other's lines.
for host in python typescript; do
echo "=== $host ==="
cat "$RESULT_DIR/$host.log"
done
pass=0
fail=0
skipped=0
failures=()
for host in python typescript; do
if [ ! -s "$RESULT_DIR/$host.tally" ]; then
failures+=("$host: no tally written (the host died before finishing)")
fail=$((fail + 1))
continue
fi
read -r host_pass host_fail host_skipped < "$RESULT_DIR/$host.tally"
pass=$((pass + host_pass))
fail=$((fail + host_fail))
skipped=$((skipped + host_skipped))
while IFS= read -r line; do
[ -n "$line" ] && failures+=("$line")
done < "$RESULT_DIR/$host.failures"
done
echo ""
echo "$pass passed, $fail failed, $skipped skipped"
for line in "${failures[@]:-}"; do
[ -n "$line" ] && echo " $line"
done
[ "$fail" -eq 0 ]