chore(586,594,485): PID-scoped E2E cleanup, ci-image-pin length guard, .gitignore core fix
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 11s
PR Gates / decisions lifecycle (pull_request) Successful in 12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m55s

Three independent CI/repo-hygiene fixes swept together; disjoint file sets.

fixes #586 — E2E cleanup is scoped by PID, never a pattern-wide pkill
  - New decision record `testing.e2e-cleanup-scope-by-pid`.
  - docs/e2e-local.md states the constraint where a BRIEF-WRITER sees it (the
    #586 root cause was a delegation gap, not agent error).
  - scripts/e2e-local.sh: reviewed against e2e-ui.sh's trap lifecycle and
    deliberately does NOT adopt it — its contract is to hand a running instance
    back to its caller, so an EXIT trap would kill the server the instant the
    launcher returned (both callers use `OUT="$(e2e-local.sh ...)"`). Recorded.
  - Instead it gains what actually prevents the incident: an lsof pre-flight
    that NAMES a foreign listener's PID rather than letting Kestrel fail its
    bind and surface as "process N exited before becoming ready".
  - Pre-flight probes BOTH bound ports, and ETV_STREAMING_PORT now defaults to
    ETV_UI_PORT. Program.cs binds a second listener whose port defaults to 8409
    independently of ETV_UI_PORT, so `ETV_UI_PORT=8420` alone still bound 8409
    and died against a foreign holder — i.e. the documented escape hatch was a
    dead end that led straight back to the confusion behind the pattern kill.

fixes #594 — ci-image-pin accepts any hex length
  - Length is a separate invariant from correctness: the resolve/staleness
    checks compare resolved shas, so an 8-char pin of the right commit passes
    green while matching NO registry tag, and all five container: jobs then die
    at image-pull with `manifest unknown` (reads like a registry outage).
  - Guard fails at the gate and prints the exact tag to use. Verified against
    doctored pins: 7 green; 6/8/10 red.
  - Uses a literal 7 rather than a derived `--short=7`: in a full clone git may
    widen an ambiguous abbreviation, demanding a pin ci-image.yml can never
    publish. Escape hatch documented inline.
  - Also fixes a pre-existing misdiagnosis: zero pins reported "MORE THAN ONE".
  - docs/ci-cd.md documents the 7-char rule and `git rev-parse --short=7 HEAD`.

fixes #485 — .gitignore `core` silently ignored `*/Core/` files
  - A bare `core` matched any path component named `core`; case-insensitively
    on macOS that swallowed every `*/Core/` SOURCE dir, so new untracked files
    were dropped by `git add -A` while tracked ones stayed fine — a clean local
    build and a CI checkout that fails to compile.
  - Now `/core` + `/core.[0-9]*`, both anchored (an unanchored `core.[0-9]*`
    would re-introduce the same silent-exclusion class this fixes).
  - Verified by diffing the full ignored-file set before/after: identical, and
    the three real Core/ dirs are trackable without -f.

Docs updated in-PR: docs/e2e-local.md, docs/ci-cd.md, docs/decisions/
workflow-process.md (+ regenerated catalog), docs/handoffs/chicorytv-issue-queue.md.

Follow-ups filed: #596 (the same shared-host reap in the Playwright-MCP
recovery record) and the ci-image.yml `--short=7` publisher-side fix, which
cannot ride this PR — editing ci-image.yml re-points ci-image-pin's `expected`
at this commit and reds the gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-25 12:46:13 +02:00
co-authored by Claude Opus 5
parent 0952078c2b
commit c8e79f49f4
8 changed files with 263 additions and 12 deletions
+32
View File
@@ -78,10 +78,42 @@ jobs:
mapfile -t pins < <(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml | cut -d: -f2 | sort -u)
echo "Image sources last changed in: ${expected}"
echo "Pins found in docker-build.yml: ${pins[*]} (${#pins[@]} distinct)"
if [ "${#pins[@]}" -eq 0 ]; then
echo "::error::No ersatztv-ci pin found in docker-build.yml at all. Every container: job must pin ersatztv-ci:<7-char-sha>; if the grep pattern stopped matching, fix it here too (docs/ci-cd.md -> 'CI toolchain image')."
exit 1
fi
if [ "${#pins[@]}" -ne 1 ]; then
echo "::error::docker-build.yml pins MORE THAN ONE ersatztv-ci tag (${pins[*]}). All jobs must pin the same image — bump them together."
exit 1
fi
# LENGTH is a separate invariant from CORRECTNESS, and only this check covers it
# (ersatztv#594). The resolve + staleness checks below compare RESOLVED shas, so a
# 8/9/10-char abbreviation of the right commit sails through them green — while
# matching NO tag in the registry, because ci-image.yml tags with
# `git rev-parse --short HEAD` under `fetch-depth: 1`, which always yields exactly 7.
# The failure would otherwise surface far downstream as all five `container:` jobs
# dying at image-pull with `manifest unknown`, which reads like a registry outage.
# This is an easy mistake to make: the natural local command prints 8 chars.
#
# Deliberately a literal 7, not a derived `git rev-parse --short=7`: in this full
# clone git may widen an ambiguous abbreviation past 7, which would demand a pin
# ci-image.yml can never publish — the exact clone-depth asymmetry noted above.
# `${expected:0:7}` is plain string truncation, so it is safe to suggest.
#
# ESCAPE HATCH, if you are ever stuck: this makes 7 mandatory, so if `${expected:0:7}` ever
# became an AMBIGUOUS prefix (two objects sharing it), the resolve check below would fail
# and a longer pin — previously the workaround — is now rejected here first. There is no
# in-repo remedy in that state: relax this length check in the same PR and say why. Note
# that ci-image.yml still tags with a plain `--short` (auto-scaled), so "always 7" is an
# empirical property of today's shallow clone, not an enforced invariant. Making the
# publisher emit `--short=7` is tracked as ersatztv#597. It is not blocked, just out of
# scope here: editing ci-image.yml re-points `expected` (above) at that commit, so it needs
# the branch's own publish-then-pin two-step (docs/ci-cd.md -> 'CI toolchain image') —
# ci-image.yml's push trigger has no branches: filter, so a feature branch does publish.
if [ "${#pins[0]}" -ne 7 ]; then
echo "::error::CI toolchain image pin ersatztv-ci:${pins[0]} is ${#pins[0]} chars, but ci-image.yml publishes 7-char tags (it tags with 'git rev-parse --short HEAD' from a fetch-depth:1 clone). A differently-sized abbreviation still resolves to the right commit, so this would pass every other check here — but NO such tag exists in the registry, and all five container: jobs would fail at image-pull time with 'manifest unknown'. Pin exactly: ersatztv-ci:${expected:0:7} (locally: git rev-parse --short=7 HEAD). See docs/ci-cd.md -> 'CI toolchain image'."
exit 1
fi
pin_full="$(git rev-parse --verify --quiet "${pins[0]}^{commit}" || true)"
if [ -z "$pin_full" ]; then
echo "::error::The pinned CI image tag ersatztv-ci:${pins[0]} does not resolve to a commit in this repo, so it cannot correspond to an image ci-image.yml built from these sources. Rebuild the image and pin the sha it prints."
+13 -1
View File
@@ -46,7 +46,19 @@ msbuild.wrn
.vs/
*.sqlite3*
core
# Core dumps. MUST stay anchored/qualified (ersatztv#485): a bare `core` matches any path
# component named `core`, and on a case-insensitive filesystem (macOS default) that includes
# every `*/Core/` source directory — silently excluding NEW files under e.g.
# ErsatzTV.Scanner/Core/ from `git add -A`. Tracked files are unaffected, so the symptom is a
# clean local build and a CI checkout that fails to compile.
#
# Both patterns are anchored to the repo root ON PURPOSE — an unanchored `core.[0-9]*` would
# re-introduce exactly the silent-exclusion class this fixes. Tradeoff, accepted: a dump written
# into a SUBdirectory is no longer ignored (the old bare `core` did catch those). In practice the
# processes that could drop one, run from the repo root or from `bin/` — and `[Bb]in/` already covers
# the latter. An un-ignored dump is visible noise; a wrongly-ignored source file is not.
/core
/core.[0-9]*
scripts/generate-api-sdk/swagger.json
scripts/download-test-content.sh
+23 -1
View File
@@ -632,6 +632,27 @@ ersatztv#299 seeded-media/scanner E2E follow-ups will need.
CI is what proves the new image works. The pin is repeated per job because
`jobs.<id>.container.image` cannot read the workflow `env` context.
**The tag is exactly 7 hex chars — get the length right, not just the commit** (ersatztv#594).
`ci-image.yml` tags with `git rev-parse --short HEAD` under `fetch-depth: 1`, and that shallow clone
holds few enough objects that git always abbreviates to **7**. A full local clone abbreviates to **8**,
so the natural command prints one character too many:
```bash
git rev-parse --short HEAD # 8 chars in a full clone — WRONG, no such registry tag
git rev-parse --short=7 HEAD # 7 chars — what ci-image.yml publishes. Use this.
```
An 8-char pin names the *right commit* but *no existing image*: it satisfies a resolve-and-compare
check, then every `container:` job dies at image-pull with `manifest unknown`, which reads like a
registry outage rather than a one-character pin error. `ci-image-pin` therefore checks the pin's
**length** as an invariant separate from its correctness, and prints the exact tag to use.
> **Caveat worth knowing before you trust the 7:** `ci-image.yml` still tags with a plain `--short`,
> whose length git *auto-scales* to the object count. 7 is therefore an empirical property of today's
> shallow clone, not an enforced invariant — if that count ever crosses git's threshold, the publisher
> emits 8, the correct pin becomes 8, and the gate's hardcoded 7 goes permanently red demanding a tag
> with no image behind it. Making the publisher emit `--short=7` is tracked as ersatztv#597.
`ci-image.yml` triggers on pushes touching `docker/ci/**`, `workflow_dispatch`, and a weekly Monday
05:00 UTC cron (base-image security updates; Gitea registers `schedule` only from `main`). It runs on
`ubuntu-latest` — it was on `small` until server-management#639, where "docker-only" was found to be
@@ -658,7 +679,8 @@ registry. Renovate tracks the Dockerfile's image pins (`dockerfile` manager, see
**Bumping the pin is enforced, not remembered.** The `ci-image-pin` job (blocking, PR-only; defined
in `pr-checks.yml`, but it greps `docker-build.yml` where the pins live) fails if
`docker-build.yml`'s pin isn't the short sha of the last commit to touch `docker/ci/**` or
`ci-image.yml`, or if the five jobs ever pin different tags. This exists because **Renovate manages
`ci-image.yml`, if that pin isn't exactly 7 chars long (see above), or if the five jobs ever pin
different tags. This exists because **Renovate manages
`docker/ci/Dockerfile`'s base pins but cannot bump an opaque `:<sha>`** in `container.image` — so a
Renovate base bump would otherwise publish a new image, test the *old* one, and merge with the
Dockerfile disagreeing with the pin. A red `ci-image-pin` means: let `ci-image.yml` publish the new
+1
View File
@@ -161,6 +161,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `spa.topbar-primary-action` | The TopBar's primary-action "+" button renders only when the active route declares a non-empty `primaryAction`, is wired (via a shared `usePrimaryAction` hook) only on single-unambiguous-create-flow list screens, and is dropped everywhere else rather than left as a dead/no-op button. | 2026-07-12 | [link](../decisions.md#2026-07-12--topbar-primary-action-button-wire-creates-drop-the-rest-238) |
| `spa.yaml-validator-textarea` | The YAML playout validator takes pasted YAML via a `<textarea>`, not a server-side file path, since the SPA has no filesystem access. | 2026-07-09 | [link](../decisions.md#2026-07-09--yaml-playout-validator-paste-textarea-instead-of-a-server-file-path) |
| `startup.parallel-orientation` | A fresh session runs two concurrent tracks at startup — Orientation (`AGENTS.md`/`CLAUDE.md` → `docs/README.md` task-signal map → the active decisions catalog `docs/decisions/README.md`) and, only when no issue is named, Selection (`scripts/select-queue.sh N`, deterministic live-Gitea ranking). A named issue skips Selection entirely. ersatztv#237, the closed pickup tracker this replaces, is reduced to a single archival breadcrumb and MUST NOT be read for live state. | 2026-07-21 | [link](../decisions.md#2026-07-21--parallel-orientation--selection-is-the-startup-protocol-237-retired-520) |
| `testing.e2e-cleanup-scope-by-pid` | An E2E harness or agent may only kill processes whose PIDs it captured at launch — capture the PID; whoever owns the lifecycle releases it from a `trap ... EXIT INT TERM`. Never `pkill -f "dotnet ErsatzTV.dll"` (or any pattern that can match a process this run did not start). A foreign listener is reported, not reaped. | 2026-07-25 | [link](workflow-process.md#2026-07-25--e2e-cleanup-kills-only-the-pids-it-started-never-a-pkill--f-pattern-586) |
| `testing.e2e-local-fresh-config-dir` | Always point `scripts/e2e-local.sh` at a fresh config dir — a reused one never emits the probe's ready line and the script kills a healthy server. | 2026-07-21 | [link](workflow-process.md#2026-07-21--run-scriptse2e-localsh-against-a-fresh-config-dir-a-reused-one-hangs-the-readiness-probe-542) |
| `testing.live-e2e-prepush-timing` | Run live-E2E via `scripts/e2e-local.sh` before pushing a write-path or UI change, and exercise download endpoints with curl, never a browser tab. | 2026-07-21 | [link](workflow-process.md#2026-07-21--live-e2e-runs-before-the-push-and-downloads-are-curled-not-browsed-542) |
| `testing.playwright-mcp-download-and-recovery` | In Playwright-MCP E2E, fetch file-download endpoints with curl — never a browser tab or `window.open` — and if browser tools stall repeatedly, `pkill -f ms-playwright-mcp` and drive a fresh session. | 2026-07-21 | [link](workflow-process.md#2026-07-21--playwright-mcp-curl-download-endpoints-never-open-a-tab-or-windowopen-542) |
+63
View File
@@ -619,3 +619,66 @@ Two further reasons the broad form is right, both of which the narrow version ha
The noise objection is answered by the escape hatch rather than by scoping: **naming a tier costs one
parameter and the hook never fires again**. The prompt is self-eliminating for anyone following the
rule, which is the habit being built.
## 2026-07-25 — E2E cleanup kills only the PIDs it started; never a `pkill -f` pattern (#586)
`key: testing.e2e-cleanup-scope-by-pid` · `status: active` · `since: 2026-07-25` · `supersedes: none` · `superseded-by: none`
**Rule:** An E2E harness or agent may only kill processes whose PIDs it captured at launch — capture the PID; whoever owns the lifecycle releases it from a `trap ... EXIT INT TERM`. Never `pkill -f "dotnet ErsatzTV.dll"` (or any pattern that can match a process this run did not start). A foreign listener is reported, not reaped.
**Signals:** `pkill -f` · `pkill -f "dotnet ErsatzTV.dll"` · E2E teardown · shared-host reap · port 8409 · port 8410 · port 8420 · `lsof -ti :$PORT` · `trap cleanup EXIT INT TERM` · truncated E2E output · delegated-brief omission · paths: `scripts/e2e-local.sh`, `scripts/e2e-ui.sh`, `docs/e2e-local.md` · issues: #586, #596, #445, #533, #440, #406
**Mechanics:** `PID=$!` at launch, `trap cleanup EXIT INT TERM` in the lifecycle-owning script; to identify a foreign listener use `lsof -ti :"$PORT"` and fail with its PID, or re-run under `ETV_UI_PORT=<other>`.
A delegated agent running the #440 live-E2E found a `dotnet ErsatzTV.dll` already on the default port
8409 that wasn't its own (mismatched `api.key`). It moved its own run to 8420 — then during cleanup ran
a broad `pkill -f "dotnet ErsatzTV.dll"`, which would have reaped the parallel #445/#533 session's
instance too. No damage that time: `scripts/e2e-ui.sh` owns its instance lifecycle and kills its server
from an EXIT/INT/TERM trap, so a free 8409 was the expected post-run state, and all four determinism
runs had already printed explicit `3 passed` summaries.
(That 8420 run could only have succeeded because the other instance had already released 8409 — as the
port section below explains, at the time `ETV_UI_PORT=8420` alone still bound 8409 as well, so it would
have failed outright against a live holder. The incident report's "correctly moved to 8420" reads as a
clean sidestep; it was closer to a coincidence. That is itself part of why the escape hatch needed
fixing rather than just documenting.)
**The near-miss is the point.** A mid-run kill of an E2E harness does not fail loudly — it truncates
output into plausible-looking-but-wrong data, which is exactly the failure class that survives review.
Same shape as the #406 peak-anon incident: reaping a shared-host resource you did not create.
**Port separation is not mitigation — and there is less of it than the incident report assumed.** A
`pkill -f "dotnet ErsatzTV.dll"` matches the *command line*, so it hits every instance regardless of
which port each one chose. Worse, the ports are not actually separated: the CI `functional-e2e` step
exports `ETV_UI_PORT=8409` (`docker-build.yml`), the *same* port local runs use — 8410 is the
`ersatztv-test` container on jazz, a different thing that the incident report conflated with it. (The
ui-E2E step added by #445 does use 8410.) Scope by PID, not by pattern and not by port.
**And "just use another port" is not a complete escape either.** `Program.cs` binds *two* listeners —
`ListenAnyIP(UiPort)` plus `ListenAnyIP(StreamingPort)` when they differ — 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 against a foreign holder. An agent that took the obvious escape hatch
would land right back in the confusion that produced the pattern kill. `scripts/e2e-local.sh` now
defaults `ETV_STREAMING_PORT` to the port it was given, and probes both before launching.
**Root cause is a delegation gap, not agent error.** The brief specified a fresh config dir (per
`testing.e2e-local-fresh-config-dir`) but said nothing about process cleanup, so the agent invented a
reasonable-looking pattern kill. An omitted rule is not an unenforced rule — it is a rule replaced by
whatever plausible default the agent reaches for. Hence the constraint lives in `docs/e2e-local.md`,
where a brief-writer reads it, and not only here.
**Known tension with `testing.playwright-mcp-download-and-recovery`,** which prescribes
`pkill -f ms-playwright-mcp` as the stall-recovery move. That is the same pattern-kill hazard this
record forbids — on a shared machine it reaps every session's MCP server, not just the stalled one —
and the rule above is deliberately general ("any pattern that can match a process this run did not
start"), so it covers that case too. That record is not line-edited here (records are superseded, not
rewritten — `docs.decision-lifecycle`); PID-scoping the MCP recovery is tracked as **#596**, which
also has to answer whether a session's own MCP server is externally identifiable at all. Until that
lands, prefer `pgrep -f ms-playwright-mcp` to identify the specific stalled PID and kill only that one.
**`scripts/e2e-local.sh` deliberately does NOT adopt the trap.** Its documented contract is the
inverse of `e2e-ui.sh`'s: it boots the server, prints `PID`/`PORT`/`CONFIG_DIR`/`LOG`, and **exits
leaving the server running** so a caller can drive the live instance. An EXIT trap there would kill the
server the instant the launcher returned, breaking every caller including `e2e-ui.sh` and the CI
`functional-e2e` step. The split is correct as it stands: `e2e-local.sh` is the launcher and hands
ownership of a single PID to its caller; `e2e-ui.sh` is a lifecycle owner and traps. What #586 changed
in `e2e-local.sh` instead is the two things that actually prevent the incident — an `lsof` pre-flight
that names a foreign listener's PID (so an agent meeting a busy port gets a diagnosis instead of
inventing one) and an explicit statement of who owns the printed PID.
+63 -5
View File
@@ -97,9 +97,51 @@ skip rubric uses (kickoff workflow lore).
provisions the admin and disables the browser setup-claim.) The machine key still works for curl seeding
regardless. Driving this with Playwright: fill the Setup/Login form before asserting any authed screen.
6. **Tear down**: kill the `dotnet ErsatzTV.dll` process and confirm the port is freed
(`lsof -i :8409` should return nothing) before starting another run — a stray process holding
the port will make the next run's health check hang or fail confusingly.
6. **Tear down**: kill the `dotnet ErsatzTV.dll` process **by the PID you captured at launch**, and
confirm the port is freed (`lsof -i :8409` should return nothing) before starting another run — a
stray process holding the port will make the next run's health check hang or fail confusingly.
> ### ⚠️ Kill by PID. Never `pkill -f` a pattern.
>
> This machine is shared by parallel sessions, and **several of them run this same binary at
> once**. `pkill -f "dotnet ErsatzTV.dll"` reaps every one of them, not just yours.
>
> ```bash
> kill "$PID" # ✅ the PID e2e-local.sh printed
> pkill -f "dotnet ErsatzTV.dll" # ❌ NEVER — reaps other sessions' servers
> ```
>
> **Filtering by port does not make a pattern kill safe** — `pkill -f` matches the *command line*,
> not the port, so it hits every instance whatever port each one chose. Nor are the ports actually
> separated: the CI `functional-e2e` step exports `ETV_UI_PORT=8409`, the **same** port local runs
> use. (8410 is the `ersatztv-test` *container* on jazz — a different thing; the ui-E2E step from
> #445 does use it.) Scope by PID, not by pattern and not by port.
>
> **If the port is already busy, that process is not yours — diagnose, don't reap.** Report it and
> move to another port; `scripts/e2e-local.sh` pre-flights this for you and prints the foreign PID:
> ```bash
> lsof -ti :8409 # who is holding it
> ETV_UI_PORT=8420 scripts/e2e-local.sh # your run, out of the way
> ```
>
> **Launching the DLL by hand? Set both ports.** `Program.cs` binds a second listener on
> `ETV_STREAMING_PORT`, which defaults to **8409 regardless of `ETV_UI_PORT`** — so
> `ETV_UI_PORT=8420 dotnet ErsatzTV.dll` still binds 8409 and dies against a foreign holder:
> ```bash
> ETV_UI_PORT=8420 ETV_STREAMING_PORT=8420 dotnet ErsatzTV.dll
> ```
> `scripts/e2e-local.sh` defaults `ETV_STREAMING_PORT` to whatever port you gave it, so through the
> script `ETV_UI_PORT=8420` alone is sufficient.
>
> Why this is a hard rule rather than a preference: killing another session's harness mid-run does
> **not** fail loudly. It truncates that run's output into plausible-looking-but-wrong data — the
> failure class that survives review. A near-miss of exactly this shape is recorded in
> `docs/decisions/workflow-process.md` → `testing.e2e-cleanup-scope-by-pid` (ersatztv#586).
>
> **Writing a brief for a delegated agent? Put this constraint in it.** The #586 incident was a
> delegation gap, not agent error: the brief specified a fresh config dir but said nothing about
> process cleanup, so the agent invented a reasonable-looking pattern kill. An omitted rule is not
> an unenforced rule — it is a rule replaced by whatever plausible default the agent reaches for.
## Playwright MCP screenshots
@@ -123,8 +165,24 @@ scripts/e2e-local.sh [CONFIG_DIR]
- Copies `ErsatzTV/wwwroot` → `ErsatzTV/bin/<config>/net10.0/wwwroot`.
- Launches `dotnet ErsatzTV.dll` in the background with `ETV_CONFIG_FOLDER` set.
- Waits (up to 120s) for the `Done migrating search index` log line.
- Prints the PID and port, then **exits leaving the server running** — the caller is responsible
for killing the PID when done (`kill <PID>`).
- **Pre-flights both bound ports** (`ETV_UI_PORT` and `ETV_STREAMING_PORT`): if something is already
listening, it fails immediately naming the offending PID, rather than letting Kestrel fail its bind
a moment later and surface as `process N exited before becoming ready` plus a log tail — a framing
that reads like a broken build. It **reports, never reaps** — on this shared machine a foreign
listener is most likely another session's harness mid-run. Re-run with `ETV_UI_PORT=<other>`.
- **Defaults `ETV_STREAMING_PORT` to `ETV_UI_PORT`** so that re-run actually works: the app binds a
second listener that otherwise defaults to 8409 no matter what the UI port is. An explicit
`ETV_STREAMING_PORT` still wins, and CI (which uses 8409) is unaffected.
- Prints the PID and port, then **exits leaving the server running** — the caller owns that PID and
is responsible for killing it when done (`kill "$PID"`, never a `pkill -f` pattern — see the
teardown warning in step 6).
- **Does not trap-and-kill on exit, by design** — unlike `scripts/e2e-ui.sh`, which owns its
instance's whole lifecycle and kills the server from an `EXIT INT TERM` trap. The two scripts sit
on opposite sides of that contract on purpose: this one is the *launcher* and hands a running
instance to its caller (an `EXIT` trap here would kill the server the instant it returned, breaking
every caller including `e2e-ui.sh` and the CI `functional-e2e` step); `e2e-ui.sh` is a *lifecycle
owner* and traps. If you write a new harness that boots and then finishes on its own, follow
`e2e-ui.sh`: capture the PID and trap.
- **`ETV_BUILD_CONFIG`** selects which build output to launch (`Debug` default for local dev; the
CI `functional-e2e` job sets `Release`). It must match the `dotnet build --configuration` you ran
first — the script only copies `wwwroot` + launches; it does not build.
+6 -2
View File
@@ -354,8 +354,12 @@ HARD CONSTRAINTS:
→ `ci.verify-locally-ci-confirms`
**Tooling gotchas**
- Playwright-MCP: curl download endpoints, never open a tab; `pkill -f ms-playwright-mcp` on repeated
stalls. → `testing.playwright-mcp-download-and-recovery`
- E2E cleanup kills only PIDs it started — never `pkill -f "dotnet ErsatzTV.dll"`; a foreign listener
is reported, not reaped. Carry this into every delegated E2E brief.
→ `testing.e2e-cleanup-scope-by-pid`
- Playwright-MCP: curl download endpoints, never open a tab; on repeated stalls prefer
`pgrep -f ms-playwright-mcp` and kill only your own PID (that record still says `pkill -f`; the same
shared-host reap is being reconciled in #596). → `testing.playwright-mcp-download-and-recovery`
- Channel branding is NOT testable through the troubleshooting-playback API.
→ `testing.troubleshoot-path-cannot-test-branding`
- Codex cheap workers launch via `codex exec`, not `spawn_agent`. → `process.codex-cheap-worker-launch`
+62 -3
View File
@@ -16,8 +16,23 @@
# PORT=<port>
# CONFIG_DIR=<dir>
# LOG=<log file path>
# and exits 0, leaving the server RUNNING in the background. The caller is responsible for
# stopping it (`kill <PID>`) and for freeing the port before starting another run.
# 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
@@ -27,9 +42,53 @@ REPO_ROOT="$(git rev-parse --show-toplevel)"
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
READY_LINE="Done migrating search index"
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"
@@ -56,7 +115,7 @@ echo "Launching dotnet ErsatzTV.dll (log: $LOG_FILE, config: $CONFIG_DIR, port:
(
cd "$BUILD_DIR"
ETV_CONFIG_FOLDER="$CONFIG_DIR" ETV_UI_PORT="$PORT" dotnet ErsatzTV.dll
ETV_CONFIG_FOLDER="$CONFIG_DIR" ETV_UI_PORT="$PORT" ETV_STREAMING_PORT="$ETV_STREAMING_PORT" dotnet ErsatzTV.dll
) >"$LOG_FILE" 2>&1 &
PID=$!