Files
ersatztv/scripts/e2e-local.sh
T
timothy 71706f3849 fix(445): silent-pass on spec failure + prove server ownership via pidfile [decisions-edit]
Second review round. Codex returned BLOCKED @ e50a2624 with three High findings;
all three were real and all three are fixed. One of them was severe and was
introduced by my OWN previous "fix" commit.

## HIGH 1 — a FAILING spec run exited 0, silently passing CI

`if ! npx playwright test; then status=$?; ... exit "$status"; fi`

Under `!` negation bash sets `$?` to the LOGICAL NEGATION of the command's
status, so inside the failure branch `$?` reads 0 — the script exited 0 on a
failing run. Verified: `if ! (exit 42); then echo $?; fi` prints 0.

A UI-E2E harness that reports success when its specs fail is worse than no
harness. My five green local runs could never have caught this: the bug lives
only on the failure path. Introduced by the log-tail improvement in e50a2624.

Fixed with `set +e` / read `$?` / `set -e`, then exit that status explicitly.
Verified: a deliberately-failing run (`--grep ZZZ_NOPE`) now exits 1.

## HIGH 2 + 3 — the pre-PID fallback needed lsof and only GUESSED ownership

The mid-boot fallback reaped "whatever LISTENS on $PORT", which was wrong twice:
  - it needed `lsof`, which is ABSENT from the CI toolchain image (verified
    directly in the published image) — so it silently no-opped precisely where
    it was needed;
  - it INFERRED ownership from the earlier pre-flight rather than proving it, so
    a process that grabbed the port after the pre-flight — or a real instance on
    a shared host — could be killed. Reaping someone else's server is worse than
    the leak it was meant to fix.

Replaced with an opt-in `ETV_PIDFILE`: e2e-local.sh writes the PID the instant
it forks, BEFORE its readiness wait, which is exactly the window a mid-boot
signal lands in. A pidfile we asked for PROVES ownership, needs no external
tool, and works in CI. The port-based kill is gone; the lsof pre-flight remains
as a friendly local check only.

Verified: the pidfile is populated while still mid-boot (readiness not yet
reached), names the real `dotnet ErsatzTV` process (not a subshell — which also
re-confirms the `exec` fix), and killing that PID alone frees the port.

Also dropped the `seq` dependency inside the trap (shell arithmetic instead),
addressing the other reviewer's busybox concern.

## Docs-reviewer finding — my stated reasoning was wrong

I justified amending `testing.e2e-local-fresh-config-dir` rather than superseding
it partly on "renaming the heading trips CI". That's a true statement that does
NOT bear on the choice: a supersession relocates to `archive/` with the heading
INTACT (verified: archive/api.md keeps the #72 heading verbatim). Corrected to
the actual reasons — the Rule never reversed, and the key is cited from
docs/handoffs/chicorytv-issue-queue.md plus two docs/superpowers/ files, which a
supersession would aim at an archived, stale-labelled record.

## Gotcha found by accident, now documented

A flawed test of mine booted two e2e-local.sh instances concurrently and the
first mysteriously failed to become ready. Cause: every run `rm -rf`s and
re-copies the SAME build-output wwwroot, so a second run yanks the static files
out from under a still-starting first instance. Documented in both the script
header and docs/e2e-local.md, because the symptom (readiness timeout, or /app
404ing) looks nothing like a shared-directory race. CI is unaffected — its curl
and UI-E2E steps are sequential.

## Budget: filed, not shaved

This PR pushes the active decisions corpus 7 lines past its 5600-line soft
budget (main was under). I trimmed my records repeatedly and each rewrite
recovered ~1 line, because the content is load-bearing; continuing would have
meant deleting useful rationale from a new convention record to hit an arbitrary
cap. The validator's own remedy is "schedule a consolidation", so that is filed
as #595 rather than paid for by starving the record. Non-blocking warning.

Also filed #594 for the pre-existing `ci-image-pin` any-hex-length weakness.

## Verification
- failing run exits 1 (was 0); passing run still 3/3 green
- pidfile written mid-boot, names the real dotnet proc, reap frees the port
- SIGTERM mid-run: exit 143, no orphan listener or process
- curl harness unaffected: 45/45 PASS; ETV_PIDFILE unset => unchanged behaviour
- decisions validator OK; zero orphaned processes after the full gate

Refs #445 #533 #594 #595
2026-07-25 14:04:52 +02:00

182 lines
9.8 KiB
Bash
Executable File

#!/usr/bin/env bash
# scripts/e2e-local.sh — launch a local ErsatzTV instance for live E2E verification.
#
# See docs/e2e-local.md for the full recipe and the "why" behind each step. This script assumes
# `dotnet build ErsatzTV.sln` and (if the SPA changed) `cd web && npm run build` have ALREADY been
# run — it only does the wwwroot copy + launch + ready-wait, since rebuilding on every invocation
# is slow and this is meant to be re-run often during a debugging session.
#
# Usage:
# scripts/e2e-local.sh [CONFIG_DIR]
#
# DO NOT run two instances of this script concurrently, even on different ports. Every run
# `rm -rf`s and re-copies the SAME build-output wwwroot (below), so a second run yanks the static
# files out from under a server that is still starting — the symptom is a readiness timeout or a
# 404-ing /app on the first instance, which looks nothing like a shared-directory race. Run them
# sequentially (the CI job's curl and UI-E2E steps do exactly that).
#
# CONFIG_DIR defaults to a fresh `mktemp -d` if omitted. Prefer a fresh dir per run: leftover
# channels/schedules/DB rows bleed state between runs and corrupt assertions. Reuse no longer
# *hangs* the readiness probe (ersatztv#533 — see READY_REGEX below), but the state-bleed reason to
# avoid it stands.
#
# On success, prints:
# PID=<pid>
# PORT=<port>
# CONFIG_DIR=<dir>
# LOG=<log file path>
# and exits 0, leaving the server RUNNING in the background.
#
# THE CALLER OWNS THE PRINTED PID. Stop the server with `kill "$PID"` using the PID printed above,
# and free the port before starting another run.
#
# NEVER `pkill -f "dotnet ErsatzTV.dll"` (ersatztv#586, `testing.e2e-cleanup-scope-by-pid`). This
# machine is shared by parallel sessions running this same binary, and a pattern kill reaps all of
# them — silently truncating another run's output into plausible-but-wrong data rather than failing
# loudly. Choosing a different port does NOT make a pattern kill safe: it matches on the command
# line, not the port. Kill the PID you started; if some other process holds the port, report it and
# move to `ETV_UI_PORT=<other>` (the pre-flight below prints the offending PID for you, and also
# moves the streaming listener — see the ETV_STREAMING_PORT note below, which is why that actually
# works here but not when you launch the DLL by hand).
#
# This script deliberately does NOT trap-and-kill on exit, unlike scripts/e2e-ui.sh: its contract is
# to hand a running instance back to its caller, so an EXIT trap would kill the server the moment the
# launcher returned. e2e-ui.sh is the lifecycle OWNER and traps; this is the launcher and does not.
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
# ETV_BUILD_CONFIG selects which build output to launch (Debug for local dev; the CI functional-E2E
# job builds Release). Must match the `dotnet build --configuration` you ran beforehand.
BUILD_CONFIG="${ETV_BUILD_CONFIG:-Debug}"
BUILD_DIR="$REPO_ROOT/ErsatzTV/bin/$BUILD_CONFIG/net10.0"
PORT="${ETV_UI_PORT:-8409}"
# The app binds TWO listeners: Program.cs does `ListenAnyIP(UiPort)` and, when they differ,
# `ListenAnyIP(StreamingPort)` — and SystemEnvironment.cs defaults StreamingPort to 8409
# INDEPENDENTLY of ETV_UI_PORT. So `ETV_UI_PORT=8420` alone still binds 8409 and dies at startup if
# another session holds it, which is the exact dead end that produced the #586 pattern-kill. Default
# the streaming port to whatever port this run was given so "move to a free port" actually works; an
# explicit ETV_STREAMING_PORT still wins. CI sets ETV_UI_PORT=8409, so this is a no-op there.
export ETV_STREAMING_PORT="${ETV_STREAMING_PORT:-$PORT}"
# Reject a non-numeric port rather than let it fail silently in the WORST possible way: `lsof -ti :abc`
# fails a service-name lookup so the pre-flight below skips it, and SystemEnvironment.cs's int.TryParse
# then falls back to 8409 — so a typo'd port makes the app bind the very port the pre-flight just
# certified as irrelevant, and the run dies against the foreign holder with exactly the confusing
# framing this script exists to prevent.
for port_var in ETV_UI_PORT:"$PORT" ETV_STREAMING_PORT:"$ETV_STREAMING_PORT"; do
case "${port_var#*:}" in
''|*[!0-9]*)
echo "error: ${port_var%%:*} must be a number, got '${port_var#*:}'. A non-numeric port is" >&2
echo " silently ignored by the app, which then falls back to binding 8409." >&2
exit 1
;;
esac
done
# Readiness marker. RebuildSearchIndexHandler logs one of TWO mutually-exclusive lines immediately
# before it calls SystemStartup.SearchIndexIsReady(), and which one depends on the config dir:
# fresh config -> "Done migrating search index in {Duration}" (the index actually migrated)
# reused config -> "Search index is already version {Version}" (nothing to migrate)
# Matching only the first made this probe hang forever on a REUSED config dir and then kill a
# perfectly healthy server (ersatztv#533). The handler's if/else is exhaustive, so this alternation
# covers every path to readiness — do not narrow it back to one line.
READY_REGEX="Done migrating search index|Search index is already version"
TIMEOUT_SECS=120
# Pre-flight (before mktemp, so a refused run leaks no config dir): name the process holding the
# port rather than letting this fail at startup with `process N exited before becoming ready` and a
# log tail — a framing that reads like a broken build instead of "something else is already
# listening" (ersatztv#586). A foreign listener is REPORTED, never reaped: on a shared machine it is
# very likely another session's harness mid-run. Both bound ports are probed, since a free UI port
# with a busy streaming port fails just as hard. `command -v` guard: lsof is absent from some
# containers, and this diagnostic must never itself be the reason a run fails.
if command -v lsof >/dev/null 2>&1; then
for probe_port in $(printf '%s\n%s\n' "$PORT" "$ETV_STREAMING_PORT" | sort -u); do
if lsof -ti :"$probe_port" >/dev/null 2>&1; then
echo "error: port $probe_port is already in use by PID(s): $(lsof -ti :"$probe_port" | tr '\n' ' ')" >&2
echo " That process is NOT yours. Do NOT 'pkill -f \"dotnet ErsatzTV.dll\"' — on this shared" >&2
echo " machine that reaps other sessions' servers mid-run (ersatztv#586)." >&2
echo " Re-run on a free port instead. Set BOTH, or the app still binds 8409:" >&2
echo " ETV_UI_PORT=8420 ETV_STREAMING_PORT=8420 $0${1:+ \"$1\"}" >&2
echo " (this script defaults ETV_STREAMING_PORT to ETV_UI_PORT, so ETV_UI_PORT=8420 alone" >&2
echo " is enough when you invoke it directly — set both when launching the app by hand.)" >&2
exit 1
fi
done
fi
CONFIG_DIR="${1:-$(mktemp -d)}"
mkdir -p "$CONFIG_DIR"
if [ ! -d "$BUILD_DIR" ]; then
echo "error: $BUILD_DIR does not exist — run 'dotnet build ErsatzTV.sln${BUILD_CONFIG:+ --configuration $BUILD_CONFIG}' first" >&2
exit 1
fi
if [ ! -d "$REPO_ROOT/ErsatzTV/wwwroot/app" ]; then
echo "warning: $REPO_ROOT/ErsatzTV/wwwroot/app does not exist — the SPA hasn't been built" \
"('cd web && npm run build'). The /app UI will 404 until it exists AND the server is" \
"(re)started after copying it in." >&2
fi
echo "Copying wwwroot into build output (static middleware resolves its file root at startup;" \
"a running process will never see files added later)..."
# rm first: with an existing destination dir, `cp -R src dst` copies INTO it (dst/wwwroot/...),
# silently leaving a previous run's stale assets in place.
rm -rf "$BUILD_DIR/wwwroot"
cp -R "$REPO_ROOT/ErsatzTV/wwwroot" "$BUILD_DIR/wwwroot"
LOG_FILE="$(mktemp)"
echo "Launching dotnet ErsatzTV.dll (log: $LOG_FILE, config: $CONFIG_DIR, port: $PORT)..."
# `exec` matters: without it, whether `$!` is the dotnet process or the wrapping SUBSHELL depends on
# bash's last-command optimization — bash 5.x collapses it to the leaf, but bash 3.2 (stock macOS
# /bin/bash, which `#!/usr/bin/env bash` picks up without Homebrew bash on PATH) reports the
# subshell. Callers then kill the subshell, the real server is orphaned holding the port, and every
# PID-based liveness check reports success. `exec` replaces the subshell with dotnet, so `$!` is the
# server on every bash.
(
cd "$BUILD_DIR"
# All three exported together, then `exec`: ETV_STREAMING_PORT is #586's (the app binds a
# second listener that otherwise defaults to 8409 regardless of ETV_UI_PORT); `exec` is #533's
# (without it `$!` is this SUBSHELL, not dotnet, on bash 3.2 — every PID-based kill then
# targets the wrong process). Both are required; neither supersedes the other.
export ETV_CONFIG_FOLDER="$CONFIG_DIR" ETV_UI_PORT="$PORT" ETV_STREAMING_PORT="$ETV_STREAMING_PORT"
exec dotnet ErsatzTV.dll
) >"$LOG_FILE" 2>&1 &
PID=$!
# Publish the PID immediately, BEFORE the readiness wait below, for a caller that must be able to
# reap the server even though it never got to parse this script's stdout — e.g. killed while this
# script is still waiting for readiness. That window is exactly where a leak would otherwise happen.
# Opt-in: unset means "behave exactly as before". scripts/e2e-ui.sh sets it before booting.
if [ -n "${ETV_PIDFILE:-}" ]; then
printf '%s\n' "$PID" >"$ETV_PIDFILE"
fi
echo "Waiting up to ${TIMEOUT_SECS}s for readiness ('$READY_REGEX')..."
elapsed=0
until grep -Eq "$READY_REGEX" "$LOG_FILE" 2>/dev/null; do
if ! kill -0 "$PID" 2>/dev/null; then
echo "error: process $PID exited before becoming ready. Log tail:" >&2
tail -n 40 "$LOG_FILE" >&2
exit 1
fi
if [ "$elapsed" -ge "$TIMEOUT_SECS" ]; then
echo "error: timed out after ${TIMEOUT_SECS}s waiting for readiness. Log tail:" >&2
tail -n 40 "$LOG_FILE" >&2
kill "$PID" 2>/dev/null || true
exit 1
fi
sleep 1
elapsed=$((elapsed + 1))
done
echo "Server ready."
echo "PID=$PID"
echo "PORT=$PORT"
echo "CONFIG_DIR=$CONFIG_DIR"
echo "LOG=$LOG_FILE"