Correct the e2e contention diagnosis for task 260 (no fix shipped yet) #205

Merged
rob merged 4 commits from serialise-e2e-ci into main 2026-08-21 08:35:36 +00:00
Owner

This PR no longer ships a fix. The reviewer's Verdict: changes needed was right on both counts, verified independently rather than accepted from either side — see the throwaway probes on (now-deleted) branch concurrency-probe / PR #208 for the raw evidence, summarised here and on task 260.

1. Job-level concurrency is a no-op on this Forgejo instance. Confirmed empirically, not just from the docs: two throwaway jobs sharing an identical job-level group ran fully concurrently for over a minute (probe-a/probe-b, both running continuously from 06:20:04 to past 06:21:14). The original concurrency: block under e2e has been removed — shipping it would have looked like a fix while doing nothing, which is worse than shipping nothing. As a direct consequence, container-images's own job-level concurrency group (its CalVer-race guard, ADR-0148) has also never worked — recorded on task 260, not fixed here, since that's a separate concern.

I also verified workflow-level concurrency is honoured here (two runs of a throwaway single-job workflow sharing a workflow-level group: the second sat waiting through the first's entire run phase and only started the instant the first reported success, while unrelated jobs from both runs ran concurrently in the same window — so it wasn't just runner-slot scarcity). But applying that to ci.yml as a whole would serialise build and container-images across every PR too, which this repo's routine 3-PR-parallel usage makes a materially bigger cost than task 260 asked to accept.

2. The original "fixed ports" diagnosis was wrong. Two jobs on this runner are network- and filesystem-isolated from each other — proven directly: a job that bound an HTTP server to its own loopback on port 5169 (self-checking the bind itself succeeded) stayed unreachable from a concurrently running sibling job retrying for a full minute; a marker file one job wrote was never visible to a sibling job retrying for the same. A literal bind collision between two e2e jobs is therefore not possible on this runner. The observed failures (#840/#841: Playwright timeouts, not bind errors; ~32min against a normal ~8) are far better explained by CPU/memory contention across the real subprocesses and Chromium each e2e job runs — the same shape of contention ADR-0091/ADR-0092 already measured and sequenced build/e2e within one run to avoid, just not yet fixed across separate runs. ci.yml's own comment on the e2e job is corrected accordingly, and ephemeral ports is now doubly rejected — not just costly to build, but pointless, since it doesn't touch the actual contended resource.

What this PR now does: corrects the diagnosis in ci.yml's own comment, drops the inert concurrency: block, and replaces E2eJobConcurrencyTests with E2eJobHasNoInertConcurrencyBlockTests — a regression guard against re-adding a job-level block that looks like protection and isn't (watched to redden by re-adding the exact block that was removed).

What's left, and why it isn't here: the properly-scoped fix is a separate workflow file for e2e, with its own workflow-level concurrency group, triggered by ci.yml's completion (workflow_run) rather than needsneeds doesn't cross workflow files, and this repo's e2e job already re-clones and rebuilds independently of build (no artefact-passing to redesign), but it's still a real change to how this pipeline is wired and untested on this runner's own quirks. That's larger than an "S" ticket. Recorded on task 260 with a request to either re-scope this ticket to the diagnosis correction (done here) or open a new ticket and allocate an ADR number for the workflow split — not self-allocated, per this repo's own convention.

**This PR no longer ships a fix.** The reviewer's `Verdict: changes needed` was right on both counts, verified independently rather than accepted from either side — see the throwaway probes on (now-deleted) branch `concurrency-probe` / PR #208 for the raw evidence, summarised here and on task 260. **1. Job-level `concurrency` is a no-op on this Forgejo instance.** Confirmed empirically, not just from the docs: two throwaway jobs sharing an identical job-level group ran fully concurrently for over a minute (`probe-a`/`probe-b`, both `running` continuously from 06:20:04 to past 06:21:14). The original `concurrency:` block under `e2e` has been removed — shipping it would have looked like a fix while doing nothing, which is worse than shipping nothing. As a direct consequence, **`container-images`'s own job-level `concurrency` group (its CalVer-race guard, ADR-0148) has also never worked** — recorded on task 260, not fixed here, since that's a separate concern. I also verified **workflow-level** `concurrency` *is* honoured here (two runs of a throwaway single-job workflow sharing a workflow-level group: the second sat `waiting` through the first's entire run phase and only started the instant the first reported `success`, while unrelated jobs from both runs ran concurrently in the same window — so it wasn't just runner-slot scarcity). But applying that to `ci.yml` as a whole would serialise `build` and `container-images` across every PR too, which this repo's routine 3-PR-parallel usage makes a materially bigger cost than task 260 asked to accept. **2. The original "fixed ports" diagnosis was wrong.** Two jobs on this runner are network- *and* filesystem-isolated from each other — proven directly: a job that bound an HTTP server to its own loopback on port 5169 (self-checking the bind itself succeeded) stayed unreachable from a concurrently running sibling job retrying for a full minute; a marker file one job wrote was never visible to a sibling job retrying for the same. A literal bind collision between two `e2e` jobs is therefore not possible on this runner. The observed failures (#840/#841: Playwright timeouts, not bind errors; ~32min against a normal ~8) are far better explained by CPU/memory contention across the real subprocesses and Chromium each `e2e` job runs — the same shape of contention ADR-0091/ADR-0092 already measured and sequenced `build`/`e2e` within one run to avoid, just not yet fixed across separate runs. `ci.yml`'s own comment on the `e2e` job is corrected accordingly, and ephemeral ports is now doubly rejected — not just costly to build, but pointless, since it doesn't touch the actual contended resource. **What this PR now does:** corrects the diagnosis in `ci.yml`'s own comment, drops the inert `concurrency:` block, and replaces `E2eJobConcurrencyTests` with `E2eJobHasNoInertConcurrencyBlockTests` — a regression guard against re-adding a job-level block that looks like protection and isn't (watched to redden by re-adding the exact block that was removed). **What's left, and why it isn't here:** the properly-scoped fix is a separate workflow file for `e2e`, with its own workflow-level `concurrency` group, triggered by `ci.yml`'s completion (`workflow_run`) rather than `needs` — `needs` doesn't cross workflow files, and this repo's `e2e` job already re-clones and rebuilds independently of `build` (no artefact-passing to redesign), but it's still a real change to how this pipeline is wired and untested on this runner's own quirks. That's larger than an "S" ticket. Recorded on task 260 with a request to either re-scope this ticket to the diagnosis correction (done here) or open a new ticket and allocate an ADR number for the workflow split — not self-allocated, per this repo's own convention.
Serialise the e2e CI job to stop it contending for its fixed ports
All checks were successful
CI / build (pull_request) Successful in 3m30s
CI / container-images (pull_request) Successful in 5m11s
CI / e2e (pull_request) Successful in 8m28s
49cd013285
Author
Owner

This states as fact that two e2e jobs "contend for the same sockets." But container-images's own comment later in this file ("Discover this job's own container network") establishes that jobs on this runner already execute inside a per-job Docker container on a Forgejo Actions-managed network — i.e. each job already has its own network namespace, so a literal bind-collision on localhost:5169 between two job containers shouldn't be possible. The observed symptom (#840/#841) was five Playwright timeouts, not a bind/EADDRINUSE error — equally consistent with CPU contention across three concurrent dotnet run subprocess sets slowing the app under test past the 30s timeout. Serialising the job would likely mask either cause, but if the real mechanism is resource contention rather than a literal port collision, that's worth stating correctly here — a future reader deciding whether ephemeral ports would fix this needs the right diagnosis, not just a fix that happens to work.

This states as fact that two `e2e` jobs "contend for the same sockets." But `container-images`'s own comment later in this file ("Discover this job's own container network") establishes that jobs on this runner already execute inside a per-job Docker container on a Forgejo Actions-managed network — i.e. each job already has its own network namespace, so a literal bind-collision on `localhost:5169` between two job containers shouldn't be possible. The observed symptom (#840/#841) was five Playwright *timeouts*, not a bind/`EADDRINUSE` error — equally consistent with CPU contention across three concurrent `dotnet run` subprocess sets slowing the app under test past the 30s timeout. Serialising the job would likely mask either cause, but if the real mechanism is resource contention rather than a literal port collision, that's worth stating correctly here — a future reader deciding whether ephemeral ports would fix this needs the right diagnosis, not just a fix that happens to work.
Author
Owner

This explains why a superseded commit of the same PR isn't cancelled, but doesn't state the larger cost of a single shared group: with three PRs routinely in flight (per task 260's own comment log), PR C's e2e job now waits behind PR A's and B's in strict FIFO order before it even starts — not just "doesn't get cancelled," but "doesn't start for 15-20+ minutes." Task 260 explicitly asked to prefer keeping e2e concurrent where workable, given this repo's routine 3-PR-parallel usage. Worth a sentence acknowledging the queueing cost across unrelated PRs, not only the narrower same-PR pre-emption cost.

This explains why a superseded commit of the *same* PR isn't cancelled, but doesn't state the larger cost of a single shared group: with three PRs routinely in flight (per task 260's own comment log), PR C's `e2e` job now waits behind PR A's *and* B's in strict FIFO order before it even starts — not just "doesn't get cancelled," but "doesn't start for 15-20+ minutes." Task 260 explicitly asked to prefer keeping `e2e` concurrent where workable, given this repo's routine 3-PR-parallel usage. Worth a sentence acknowledging the queueing cost across unrelated PRs, not only the narrower same-PR pre-emption cost.
Author
Owner

Forgejo's own docs (reference page, concurrency section) state plainly: "Multiple jobs within a workflow are not affected by the concurrency setting" — concurrency is a workflow-level key only. This block is nested under e2e:, i.e. job-level, so Forgejo almost certainly ignores it entirely (the same way it already ignores job-level permissions:, per container-images's own comment further down this file). If so, this is a complete no-op: the YAML still parses, run #851 is genuinely green at job level, and nothing is actually serialised. The PR body's claim that Forgejo "follows GitHub's own concurrency semantics" for this, "confirmed against this file's own container-images group," isn't independent confirmation — container-images's group is at the same (job) level and, on this reading, is equally likely to be a pre-existing no-op that has just never been tested under real contention. Before merging, either move concurrency to the workflow's top level (accepting that it then also gates build/container-images, which is a materially different design), or produce direct evidence — e.g. Forgejo source, or an actual three-way overlap that this PR's own evidence trail says hasn't been reproduced yet — that job-level concurrency is honoured on this instance.

Forgejo's own docs (reference page, `concurrency` section) state plainly: "Multiple jobs within a workflow are not affected by the `concurrency` setting" — `concurrency` is a workflow-level key only. This block is nested under `e2e:`, i.e. job-level, so Forgejo almost certainly ignores it entirely (the same way it already ignores job-level `permissions:`, per `container-images`'s own comment further down this file). If so, this is a complete no-op: the YAML still parses, run #851 is genuinely green at job level, and nothing is actually serialised. The PR body's claim that Forgejo "follows GitHub's own concurrency semantics" for this, "confirmed against this file's own `container-images` group," isn't independent confirmation — `container-images`'s group is at the same (job) level and, on this reading, is equally likely to be a pre-existing no-op that has just never been tested under real contention. Before merging, either move `concurrency` to the workflow's top level (accepting that it then also gates `build`/`container-images`, which is a materially different design), or produce direct evidence — e.g. Forgejo source, or an actual three-way overlap that this PR's own evidence trail says hasn't been reproduced yet — that job-level `concurrency` is honoured on this instance.
@ -0,0 +12,4 @@
/// the same pull request from an unrelated one — see the job's own comment in <c>ci.yml</c> for
/// the full reasoning. Nothing compiles this rule; only reading <c>ci.yml</c> as text can.
/// </summary>
public sealed class E2eJobConcurrencyTests
Author
Owner

Re-ran these two regexes by hand against the real e2e block plus four mutations (cancel-in-progress reverted to true, group scoped per-PR, block commented out, block deleted) — all four correctly redden, and the \s* (not .*) between concurrency:/group:/cancel-in-progress: correctly refuses to match across a # comment prefix, so a comment merely mentioning the marker doesn't fool it. The extraction and regex design are solid. But given the job-level-concurrency question raised on ci.yml: this proves the text says the right thing, not that Forgejo acts on it. Worth being explicit in the class doc that this is a text-shape guard only and cannot detect "technically present, semantically inert" — which is exactly the risk here.

Re-ran these two regexes by hand against the real e2e block plus four mutations (cancel-in-progress reverted to true, group scoped per-PR, block commented out, block deleted) — all four correctly redden, and the `\s*` (not `.*`) between `concurrency:`/`group:`/`cancel-in-progress:` correctly refuses to match across a `#` comment prefix, so a comment merely mentioning the marker doesn't fool it. The extraction and regex design are solid. But given the job-level-`concurrency` question raised on ci.yml: this proves the *text* says the right thing, not that Forgejo *acts* on it. Worth being explicit in the class doc that this is a text-shape guard only and cannot detect "technically present, semantically inert" — which is exactly the risk here.
rob left a comment

Verdict: changes needed

The fix places concurrency: under the e2e job (job-level). Forgejo's own docs are explicit: "Multiple jobs within a workflow are not affected by the concurrency setting" — it's a workflow-level key only. This block almost certainly parses and is silently ignored, exactly the failure mode task 260 worries about: CI stays green, nothing is actually serialised. The PR's claim that this is "confirmed against this file's own container-images group" doesn't hold up — that group is at the same (job) level and is equally unverified; citing it as precedent isn't independent evidence. See inline comment on the concurrency: block.

Separately, worth resolving before this ships regardless of the above: container-images's own comments establish jobs already run in per-job Docker containers with their own network namespace, which undercuts the literal "contend for the same sockets" diagnosis — the observed failures were timeouts, not bind errors, and are equally consistent with CPU contention. And the "cost accepted" reasoning for cancel-in-progress: false only covers same-PR pre-emption, not the larger cost that a shared group now queues every PR's e2e job behind every other's — the opposite of what task 260 asked to be weighed, given three PRs routinely in flight.

The tripwire itself (E2eJobConcurrencyTests) is well-built for what it checks — re-derived by hand against four mutations (cancel-in-progress reverted, group scoped per-PR, block commented out, block deleted) and all four correctly redden. But it only proves the YAML text says the right thing; it can't tell a live setting from an inert one, which is the actual open question here.

CI on this PR (#851, SHA 49cd013) is genuinely green at job level (build/e2e/container-images), independently re-checked — but that's expected whether or not the concurrency block does anything, since a solo run isn't contended either way. Nothing here disputes the honesty of the PR's own "does not prove a real overlap" disclosure; that's the right call. The blocker is the job-level placement itself.

Verdict: changes needed The fix places `concurrency:` under the `e2e` job (job-level). Forgejo's own docs are explicit: "Multiple jobs within a workflow are not affected by the `concurrency` setting" — it's a workflow-level key only. This block almost certainly parses and is silently ignored, exactly the failure mode task 260 worries about: CI stays green, nothing is actually serialised. The PR's claim that this is "confirmed against this file's own `container-images` group" doesn't hold up — that group is at the same (job) level and is equally unverified; citing it as precedent isn't independent evidence. See inline comment on the `concurrency:` block. Separately, worth resolving before this ships regardless of the above: `container-images`'s own comments establish jobs already run in per-job Docker containers with their own network namespace, which undercuts the literal "contend for the same sockets" diagnosis — the observed failures were timeouts, not bind errors, and are equally consistent with CPU contention. And the "cost accepted" reasoning for `cancel-in-progress: false` only covers same-PR pre-emption, not the larger cost that a shared group now queues *every* PR's `e2e` job behind every other's — the opposite of what task 260 asked to be weighed, given three PRs routinely in flight. The tripwire itself (`E2eJobConcurrencyTests`) is well-built for what it checks — re-derived by hand against four mutations (cancel-in-progress reverted, group scoped per-PR, block commented out, block deleted) and all four correctly redden. But it only proves the YAML text says the right thing; it can't tell a live setting from an inert one, which is the actual open question here. CI on this PR (#851, SHA 49cd013) is genuinely green at job level (build/e2e/container-images), independently re-checked — but that's expected whether or not the concurrency block does anything, since a solo run isn't contended either way. Nothing here disputes the honesty of the PR's own "does not prove a real overlap" disclosure; that's the right call. The blocker is the job-level placement itself.
Correct the e2e contention diagnosis and drop the inert concurrency block
Some checks failed
CI / build (pull_request) Failing after 12m19s
CI / e2e (pull_request) Has been skipped
CI / container-images (pull_request) Has been skipped
581f2446a2
rob changed title from Serialise the e2e CI job to stop it contending for fixed ports to Correct the e2e contention diagnosis for task 260 (no fix shipped yet) 2026-08-21 06:50:17 +00:00
Retrigger CI for a clean read after the runner's own Docker-daemon contention
Some checks failed
CI / build (pull_request) Failing after 39m27s
CI / e2e (pull_request) Has been cancelled
CI / container-images (pull_request) Has been cancelled
f0040f174f
Empty commit: retrigger CI now the runner has recovered and is serialised
All checks were successful
CI / build (pull_request) Successful in 4m12s
CI / container-images (pull_request) Successful in 5m23s
CI / e2e (pull_request) Successful in 8m59s
85c7b40db1
rob left a comment

Verdict: mergeable

Re-derived independently, not accepted on the author's say-so:

  • Rebuilt E2eJobHasNoInertConcurrencyBlockTests' regex against the actual ci.yml and against a copy with the exact round-1 job-level concurrency: block (group: e2e-fixed-ports, cancel-in-progress: false) reinserted under e2e. Passes clean, reddens on the mutation. The extraction sanity check also correctly finds needs: build/localhost:5169 in both, so it isn't passing on an empty match.
  • Grepped the whole file for port/collision language — nothing stale survives outside the corrected comment block.
  • Checked the "run #876 cancelled by #878" complication against list_workflow_runs: cancelled push runs on main from a superseded merge are a routine, longstanding pattern here (8 instances back to 2026-08-11, long before any concurrency: block existed in this file), and ci.yml carries no workflow-level concurrency key at all. So that cancellation is some other, pre-existing Forgejo/act_runner behaviour, not evidence about job-level concurrency — it doesn't touch the mechanism the PR and the new test actually make claims about. The PR's wording throughout stays scoped to "an explicit job-level concurrency: key does nothing here," never to "nothing on this runner is ever superseded," so there's no overreach to correct.
  • Confirmed task 260 carries the corrected finding and task 271 exists with ADR-0171 allocated and an explicit "depends on PR #205 merging first" — a reader following the pointer lands somewhere real.
  • CI: run #877, head 85c7b40, push/pull_request filtered by head_sha — success, 13m12s, matching the SHA directly rather than by number.

One non-blocking thought for whoever picks this up in task 271: the assertion's regex (^ concurrency:\s*$) requires the line to end right after the colon, so a reintroduction with a trailing inline comment (concurrency: # scoped to this job) would slip past it. Given this project's own history with narrow source-text tripwires (ADR-0066), worth either loosening the regex or adding the same "best-effort, not exhaustive" disclaimer FieldErrorsValidatesOnSubmitOnlyTests carries — not blocking, since the test still catches the one mistake it was written against and CLAUDE.md's own standard for these guards is "watched to redden," which this passes.

No remedy shipping here is the right call, and it's stated plainly — title, PR body and the ci.yml comment all agree nothing is serialised yet and point at task 271 for the real fix.

Verdict: mergeable Re-derived independently, not accepted on the author's say-so: - Rebuilt `E2eJobHasNoInertConcurrencyBlockTests`' regex against the actual `ci.yml` and against a copy with the exact round-1 job-level `concurrency:` block (`group: e2e-fixed-ports`, `cancel-in-progress: false`) reinserted under `e2e`. Passes clean, reddens on the mutation. The extraction sanity check also correctly finds `needs: build`/`localhost:5169` in both, so it isn't passing on an empty match. - Grepped the whole file for port/collision language — nothing stale survives outside the corrected comment block. - Checked the "run #876 cancelled by #878" complication against `list_workflow_runs`: cancelled `push` runs on `main` from a superseded merge are a routine, longstanding pattern here (8 instances back to 2026-08-11, long before any `concurrency:` block existed in this file), and `ci.yml` carries no workflow-level `concurrency` key at all. So that cancellation is some other, pre-existing Forgejo/act_runner behaviour, not evidence about job-level `concurrency` — it doesn't touch the mechanism the PR and the new test actually make claims about. The PR's wording throughout stays scoped to "an explicit job-level `concurrency:` key does nothing here," never to "nothing on this runner is ever superseded," so there's no overreach to correct. - Confirmed task 260 carries the corrected finding and task 271 exists with ADR-0171 allocated and an explicit "depends on PR #205 merging first" — a reader following the pointer lands somewhere real. - CI: run #877, head `85c7b40`, `push`/`pull_request` filtered by `head_sha` — success, 13m12s, matching the SHA directly rather than by number. One non-blocking thought for whoever picks this up in task 271: the assertion's regex (`^ concurrency:\s*$`) requires the line to end right after the colon, so a reintroduction with a trailing inline comment (`concurrency: # scoped to this job`) would slip past it. Given this project's own history with narrow source-text tripwires (ADR-0066), worth either loosening the regex or adding the same "best-effort, not exhaustive" disclaimer `FieldErrorsValidatesOnSubmitOnlyTests` carries — not blocking, since the test still catches the one mistake it was written against and CLAUDE.md's own standard for these guards is "watched to redden," which this passes. No remedy shipping here is the right call, and it's stated plainly — title, PR body and the `ci.yml` comment all agree nothing is serialised yet and point at task 271 for the real fix.
rob merged commit 6fd6dd02a6 into main 2026-08-21 08:35:36 +00:00
rob deleted branch serialise-e2e-ci 2026-08-21 08:35:36 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/PlaceMark!205
No description provided.