#!/usr/bin/env bash # Offline regression test for ui-drive.sh's rs485 simulator startup and its # process cleanup: no real QEMU or kernel image. A fake qemu/run.sh stands in # for the VM (it optionally binds the rs.sock qemu's --rs485 chardev would # create) and, for one case, a fake socat stands in ahead of it on PATH. This # is the same "copy the real script beside stand-in siblings" trick # tests/flows/run-flow-run-all-tests.sh uses for flow-run-rig.sh, one layer # down at ui-drive.sh's own dependencies. # # What is worth pinning: # - the rs.sock/rs.pty/rs.ctl waits fail closed instead of printing # "== rs485 simulator: ..." over a bus nothing is actually serving -- # before this fix, a socat or mbsim.py that never came up looked, from # ui-drive.sh's own output, exactly like a live one, and every later # rs485 assertion failed for a reason buried in a log nobody was pointed # at. # - cleanup() escalates to SIGKILL for a sim process that ignores SIGTERM, # instead of leaving it to outlive the script (an accumulation of # orphaned simulator processes on a loaded CI runner). # # bash test-ui-drive-rs485.sh set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRATCH="$(mktemp -d /tmp/uidrs485.XXXXXX)" trap 'rm -rf "$SCRATCH"' EXIT FAIL=0 pass() { printf '[PASS] %s\n' "$1"; } fail() { printf '[FAIL] %s\n' "$1"; FAIL=1; } ZIMAGE="$SCRATCH/fake-zImage" SCRIPT="$SCRATCH/fake.txt" : > "$ZIMAGE" printf 'wake\n' > "$SCRIPT" # build_rig WORKDIR: lay out WORKDIR/qemu with a copy of the real (fixed) # ui-drive.sh and qmp.py next to stand-in mkinitramfs.sh/mkimage.sh/run.sh/ # payload, so ui-drive.sh's own HERE/QDIR resolve inside WORKDIR and nothing # under the real qemu/out/ is ever touched. build_rig() { local work="$1" mkdir -p "$work/qemu/tests" "$work/qemu/payload" cp "$HERE/ui-drive.sh" "$work/qemu/tests/ui-drive.sh" cp "$HERE/qmp.py" "$work/qemu/tests/qmp.py" chmod +x "$work/qemu/tests/ui-drive.sh" printf '#!/usr/bin/env bash\nexit 0\n' > "$work/qemu/mkinitramfs.sh" printf '#!/usr/bin/env bash\nexit 0\n' > "$work/qemu/mkimage.sh" cat > "$work/qemu/run.sh" <<'EOS' #!/usr/bin/env bash # Stand-in for qemu/run.sh: no real VM, just enough to drive ui-drive.sh's # rs485 startup logic offline. TEST_MAKE_RS_SOCK=1 binds a listening AF_UNIX # socket at --rs485's path (as qemu's server=on,wait=off chardev would); # unset/0 leaves it absent, simulating qemu never wiring up the bus. Stays # up 8s -- past ui-drive.sh's 3s post-launch liveness check and the rs485 # waits' poll budgets -- then exits, so a run that gets past the rs485 block # still fails fast (VM exited early) instead of idling out the 180s deadline. rs485_sock="" while [ $# -gt 0 ]; do case "$1" in --rs485) rs485_sock="$2"; shift 2 ;; *) shift ;; esac done exec python3 -c ' import os, socket, sys, time sock_path = sys.argv[1] if len(sys.argv) > 1 else "" if sock_path and os.environ.get("TEST_MAKE_RS_SOCK") == "1": s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.bind(sock_path) s.listen(1) time.sleep(8) ' "$rs485_sock" EOS chmod +x "$work/qemu/mkinitramfs.sh" "$work/qemu/mkimage.sh" "$work/qemu/run.sh" : > "$work/qemu/payload/warden-ui" chmod +x "$work/qemu/payload/warden-ui" } # run_rig WORKDIR: invoke the copied ui-drive.sh with a bounded outer timeout # (the test's own safety net, matching run-flow-run-all-tests.sh) and stash # combined output + exit code in the globals below. RIG_OUT="" RIG_RC=0 run_rig() { local work="$1" RIG_OUT="$(cd "$work" && timeout -k 5 40 bash "$work/qemu/tests/ui-drive.sh" \ --rs485-devices "5:bare" "$ZIMAGE" "$SCRIPT" "$work/out" 2>&1)" RIG_RC=$? } # --- case A: qemu's rs.sock never appears ----------------------------------- work="$SCRATCH/a"; build_rig "$work" FLARE_EDGE="$SCRATCH/flare-edge-a" mkdir -p "$FLARE_EDGE/tools/modbus-sim" printf '#!/usr/bin/env python3\nimport sys; sys.exit(1)\n' > "$FLARE_EDGE/tools/modbus-sim/mbsim.py" # A VAR=val prefix on a function call exports VAR into that one call only # (and any children it spawns) and restores whatever VAR held before once the # call returns -- the same per-case scoping a wrapping subshell gave us, but # without a subshell: run_rig's RIG_OUT/RIG_RC writes land here directly, so # nothing needs to be re-serialized as text and re-parsed back out below. FLARE_EDGE="$FLARE_EDGE" TEST_MAKE_RS_SOCK='' run_rig "$work" [ "$RIG_RC" -ne 0 ] \ && pass "case A: never-appeared rs.sock fails the run (rc=$RIG_RC)" \ || fail "case A: never-appeared rs.sock fails the run: rc=$RIG_RC, out: $RIG_OUT" printf '%s\n' "$RIG_OUT" | grep -qF 'FATAL: rs485 bus socket never appeared' \ && pass "case A: FATAL names the missing rs.sock" \ || fail "case A: FATAL names the missing rs.sock: $RIG_OUT" printf '%s\n' "$RIG_OUT" | grep -qF '== rs485 simulator:' \ && fail "case A: printed the rs485-live success line over a bus that was never up: $RIG_OUT" \ || pass "case A: never claims the bus is live" # --- case B: rs.sock is up but socat never creates rs.pty ------------------- work="$SCRATCH/b"; build_rig "$work" FLARE_EDGE="$SCRATCH/flare-edge-b" mkdir -p "$FLARE_EDGE/tools/modbus-sim" printf '#!/usr/bin/env python3\nimport sys; sys.exit(1)\n' > "$FLARE_EDGE/tools/modbus-sim/mbsim.py" FAKEBIN="$SCRATCH/fakebin-b"; mkdir -p "$FAKEBIN" printf '#!/usr/bin/env bash\necho "FAKE SOCAT: simulated failure" >&2\nexit 1\n' > "$FAKEBIN/socat" chmod +x "$FAKEBIN/socat" FLARE_EDGE="$FLARE_EDGE" TEST_MAKE_RS_SOCK=1 PATH="$FAKEBIN:$PATH" run_rig "$work" [ "$RIG_RC" -ne 0 ] \ && pass "case B: socat never linking rs.pty fails the run (rc=$RIG_RC)" \ || fail "case B: socat never linking rs.pty fails the run: rc=$RIG_RC, out: $RIG_OUT" printf '%s\n' "$RIG_OUT" | grep -qF 'FATAL: rs485 socat never created rs.pty' \ && pass "case B: FATAL names the missing rs.pty" \ || fail "case B: FATAL names the missing rs.pty: $RIG_OUT" # --- case C: rs.sock+rs.pty are up but mbsim.py crashes before rs.ctl ------- work="$SCRATCH/c"; build_rig "$work" FLARE_EDGE="$SCRATCH/flare-edge-c" mkdir -p "$FLARE_EDGE/tools/modbus-sim" cat > "$FLARE_EDGE/tools/modbus-sim/mbsim.py" <<'EOS' #!/usr/bin/env python3 import sys sys.stderr.write("FAKE MBSIM: simulated crash before opening the control socket\n") sys.exit(1) EOS FLARE_EDGE="$FLARE_EDGE" TEST_MAKE_RS_SOCK=1 TEST_MBSIM_PID_FILE='' run_rig "$work" [ "$RIG_RC" -ne 0 ] \ && pass "case C: mbsim.py crashing before rs.ctl fails the run (rc=$RIG_RC)" \ || fail "case C: mbsim.py crashing before rs.ctl fails the run: rc=$RIG_RC, out: $RIG_OUT" printf '%s\n' "$RIG_OUT" | grep -qF 'FATAL: rs485 simulator (mbsim.py) never came up' \ && pass "case C: FATAL names the dead simulator" \ || fail "case C: FATAL names the dead simulator: $RIG_OUT" printf '%s\n' "$RIG_OUT" | grep -qF 'FAKE MBSIM: simulated crash' \ && pass "case C: mbsim.log is dumped on failure" \ || fail "case C: mbsim.log is dumped on failure: $RIG_OUT" # --- case D: a sim process that ignores SIGTERM is still reaped ------------ work="$SCRATCH/d"; build_rig "$work" FLARE_EDGE="$SCRATCH/flare-edge-d" mkdir -p "$FLARE_EDGE/tools/modbus-sim" cat > "$FLARE_EDGE/tools/modbus-sim/mbsim.py" <<'EOS' #!/usr/bin/env python3 # Comes up cleanly (binds --control so ui-drive.sh's rs.ctl wait passes), # then ignores SIGTERM -- the process cleanup() must still SIGKILL. import argparse, os, signal, socket, sys, time ap = argparse.ArgumentParser() ap.add_argument('--port') ap.add_argument('--control') ap.add_argument('--device', action='append', default=[]) args = ap.parse_args() signal.signal(signal.SIGTERM, signal.SIG_IGN) if args.control: try: os.unlink(args.control) except FileNotFoundError: pass s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.bind(args.control) s.listen(1) pidfile = os.environ.get('TEST_MBSIM_PID_FILE') if pidfile: with open(pidfile, 'w') as f: f.write(str(os.getpid())) while True: time.sleep(1) EOS PIDFILE="$SCRATCH/mbsim-d.pid" FLARE_EDGE="$FLARE_EDGE" TEST_MAKE_RS_SOCK=1 TEST_MBSIM_PID_FILE="$PIDFILE" run_rig "$work" if [ -s "$PIDFILE" ]; then mbsim_pid="$(cat "$PIDFILE")" if ! kill -0 "$mbsim_pid" 2>/dev/null; then pass "case D: a SIGTERM-ignoring sim process is dead once ui-drive.sh returns (SIGKILL fallback)" else fail "case D: pid $mbsim_pid is still alive after ui-drive.sh returned -- SIGKILL fallback did not fire" kill -KILL "$mbsim_pid" 2>/dev/null || true # don't leak it out of the test either fi else fail "case D: the fake mbsim.py never wrote its pid file, out: $RIG_OUT" fi [ "$FAIL" -eq 0 ] && echo "ALL UI-DRIVE RS485 TESTS PASSED" || echo "UI-DRIVE RS485 TESTS FAILED" [ "$FAIL" -eq 0 ]