#!/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= # PORT= # CONFIG_DIR= # LOG= # 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=` (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"