Skip to content

ci: run coverage in set mode and -race on concurrent packages - #1311

Open
fuad-daoud wants to merge 32 commits into
mainfrom
ci/race-coverage-split
Open

fuad-daoud wants to merge 32 commits into
mainfrom
ci/race-coverage-split

Conversation

@fuad-daoud

@fuad-daoud fuad-daoud commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

make coverage ran the whole suite once under -race -covermode=atomic. That run was the slowest step of every pull request, and atomic coverage under -race can hide a data race: #1271 has a planted race that -race reports 6 of 6 times and -race -covermode=atomic reports 0 of 6. This splits the run in two.

  • make coverage runs go test ./... -covermode=set without -race. cmd/morphic-covergate reads only whether a block ran, so its verdict is unchanged: 100% of 16,833 statements on this branch.
  • make race runs -race alone, on the packages listed in scripts/race-packages.txt: the ones whose tests run morphic code on more than one goroutine (load, irfacts, engine/..., cmd/morphic, barrier, leakcheck, ctxtest). It skips TestLoad_ACancelAtAnyCheckEndsTheLoadCleanly and TestLoad_DoesNextToNothingOnceItRefusesACancel, which test sequential cancellation and still run at full size in make coverage.
  • make race proves it reaches: scripts/race-reach.sh runs the race command on a planted race under internal/archtest/testdata/racereach and fails unless it is reported. The plant's racing accesses sit in a non-test file, so both regressions fail the gate: dropping -race, and merging coverage back into the race run (the plant is reported 6 of 6 under -race and 0 of 6 with -covermode=atomic added). ci: run coverage in set mode, and -race only where code is concurrent #1271 records that the hiding depends on layout, so a later toolchain could weaken the coverage half; it can never redden a correct recipe.
  • The list cannot drift. TestRaceList_NamesEveryConcurrentPackage fails when a package whose production code takes a lock, an atomic or a go statement, or whose tests start goroutines, is neither listed nor exempt with its reason, and when an entry or exemption is stale. It reads the same file the Makefile does.
  • CI's suite job runs coverage and race as separate steps, under a name that says so. Its id stays suite, so its cache key is unchanged.
  • Comments, documents and skills that described the combined run now describe this one.

What the race run gives up, deliberately:

  • Race checking of tests that run morphic code on one goroutine. Every test still runs, without -race, in make coverage.
  • internal/harness and internal/census: tooling whose tests call harness.Check and (*Census).Add from parallel goroutines. A race there goes unreported. The list file says so.
  • TestEngine_ConcurrentRunSharesOneEngine passes RunOptions{}, which leaves Verify off. It race-checks the compile and the passes; other listed tests reach Verify.
  • CI no longer meets the go/types race in archtest's loads (test(archtest): the recursion pin's load can trip a go/types data race #855).

A scripts/vet-rules.sh fix that #1271 neither needs nor exposes was moved out to #1305.

Closes #1271.

Before and after

flowchart LR
  subgraph before["Before: one combined run"]
    direction TB
    b1["make coverage<br/>go test ./... -race -covermode=atomic"] --> b2["every package under the race detector<br/>284–386s on a one-file change"]
    b1 -. "atomic counters can act as<br/>synchronisation the detector trusts" .-> b3["some races go unreported"]
  end
  subgraph after["After: two runs"]
    direction TB
    a1["make coverage<br/>go test ./... -covermode=set"] --> a2["100% gate, same verdict<br/>77–85s"]
    a3["make race<br/>-race on scripts/race-packages.txt"] --> a4["only packages that run code concurrently<br/>24–95s"]
    a3 --> a5["race-reach.sh: a planted race<br/>must be reported"]
    a6["TestRaceList_NamesEveryConcurrentPackage"] -. "holds the list to the tree" .-> a3
  end
  before ~~~ after
Loading

What it brings: the race detector is no longer silenced by coverage and is proven to report a planted race, and the suite job stops paying for it on packages whose code never runs concurrently. On the fork's measurements, the race run fell from 284–386s (combined) to 24–95s, with coverage alone at 77–85s.

Test plan

  • make gate passes on this branch, pinned to 4 cores (GOMAXPROCS=4 taskset -c …), on the tree merged with main at 39afce9. main at 7cf3f95 is merged in since; on that merge the race-list rule passes, main adds no race-detector mention and no production package that takes a lock or starts a goroutine, and CI on this head runs the gate.
  • The workflow change ran on Actions in a test pull request on a fork (check: #1271 workflow on CI fuad-daoud/morphic#1): run 41360252466 on an earlier head and 41365106637 on this one. The coverage step ends Coverage gate passed: all 16833 statements covered.. The race step runs exactly the seven listed patterns (11 packages) with the two tests skipped, then the reach check.
  • make race's reach: removing -race from the recipe, adding -covermode=atomic to it, or making the plant race-free, each fails scripts/race-reach.sh.
  • TestRaceList_* and TestRaceListViolations_* each fail under 12 planted defects: an entry dropped, an unlisted sync import, an unlisted go statement, a stale exemption, the / boundary in matching removed, test-only imports counted, and others. Each exemption's reason was read against every goroutine site in its package.
  • TestRun_JudgesAProfile gained a mode: set row, which reddens when set-mode blocks are judged missed.
  • Every -race, race detector and covermode mention outside testdata was read and classified: those in listed packages stay, and those in packages that left the race run were reworded.
  • After merge: judge timing on a run once main has saved a cache under the new steps. The first pull-request run restores a cache built for the combined run.

`make coverage` now runs the suite once in set mode, without -race.
cmd/morphic-covergate reads only whether a block ran, so set mode gives
it the same verdict, and a new row in its test pins that reading.

`make race` runs -race alone over the packages in
scripts/race-packages.txt, the ones whose code runs on more than one
goroutine. Combined with atomic coverage the detector missed a race
planted for the purpose that it caught alone, so the two no longer share
a run. `make gate` runs both.

The race run skips the two load tests of sequential cancellation, which
the detector learns nothing from; they still run in full in coverage.

Refs #1271
internal/harness and internal/census stay off scripts/race-packages.txt,
as #1271 decided. Their tests call harness.Check and (*Census).Add on
parallel goroutines, so with -race gone from make coverage a race in
either is no longer detected. The list file now says so.
The suite job now runs coverage and the race detector as separate
steps, `make coverage` then `make race`. The job id is kept, so its
cache key is unchanged.
A directory is flagged as running concurrently when a non-test file
imports sync or sync/atomic, or any file, tests included, starts a
goroutine by a go statement or a single-argument .Go call. Test-only
sync imports and t.Parallel do not count.

Every flagged directory must be covered by an entry of
scripts/race-packages.txt or exempt in raceExempt with a reason. An
entry or exemption that flags nothing fails too, as does a directory
that is both listed and exempt.

The test reads the same file the Makefile does, through os.ReadFile, so
Go's test cache re-runs it when the list is edited.
go vet replays a cached finding with the path of the tree that produced
it, so the second tree to vet identical content saw an absolute path
and the plant check refused its own plant.
A sibling directory whose name only starts with a p/... entry's name,
such as px beside p/..., is not covered by it. go test ./p/... does not
run that sibling, so the rule must report it, and a planted case now
holds that.

A race-list line naming a path outside the root (./../a) or the root as
a dot (./.) is refused, and each refusal now has a row of its own.
Two Go comments described the combined run: Engine's doc said -race was
what the coverage gate uses, and the detect test said -race was the
gate's only test step. The first now names `make race`; the second gives
the 64 MiB case's cost under the coverage run's set mode, measured, in
place of a -race figure that no longer describes any gate step.

CLAUDE.md, docs/architecture.md and CONTRIBUTING.md now list the rule
that holds scripts/race-packages.txt to the packages that run
concurrently, and CONTRIBUTING's compiler recipe names its test.
Three comments described the combined run, not two: the first sweep
matched exact phrases and missed detect_test.go:112, where
TestCompiler_SourceLimitReadsTheCallersOptions justified its design by a
gate race run that no longer covers compilers/openapi. It now cites the
set-mode cost that TestDetect_ReadsASourceWithinTheCallersByteBudget
measures.

CLAUDE.md, docs/architecture.md and CONTRIBUTING.md now state the
race-list rule by what TestRaceList_NamesEveryConcurrentPackage counts:
a sync or sync/atomic import in a non-test file, or a go statement or
single-argument .Go(...) call in any file. Packages whose tests only run
in parallel are not flagged. CONTRIBUTING names the raceExempt route.
The skills now name the set-mode coverage build and the race build of
the packages `make race` lists, in place of the combined
`-race -covermode=atomic` run that `make coverage` no longer performs.
The allocation-count rule and its summary in the performance skill now
name the race run as a separate `go test -race` run, because `go test`
refuses `-race` together with `-covermode=set`.
Rewrap the childTimeout and probeMaxStack comments as whole paragraphs after their rewording; no words change.
make race passed with -race dropped from its recipe, as the packages it lists are race-free. scripts/race-reach.sh runs the recipe's own command over a race planted in internal/archtest/testdata/racereach and fails unless a data race naming the plant is reported, as scripts/vet-rules.sh does for make vet.
The figure of about 2 s and 430 MiB held for one source shape. A 65 MiB comment costs 1.2 s and 337 MiB; 65 MiB of paths entries costs 9.8 s and 2028 MiB. Say what holds for any shape.
Reverts 389d1b1, which reads the vet plant's file by its path below the module root. It fixes a scripts/vet-rules.sh bug that the race-list change neither needs nor exposes, so it moves to #1305.
Both readers of the list, the Makefile's sed and the archtest parser, take an indented # line as a comment.
The plant is test code, which coverage does not instrument, so atomic
coverage cannot hide it the way it hid the same race in production code.
The comments no longer say otherwise.
The reach script checks only the command passed to it. The Makefile now
says that the recipe's first line is held only while it runs
$(RACE_TEST).
The reach script holds only the command it is passed. The race recipe's
first line is the empty-list guard, so the comment now names the line it
means: the test of $(RACE_PACKAGES), which is held only while it runs
$(RACE_TEST).
The plant's accesses were test code, which coverage does not instrument, so
a race run with atomic coverage merged back in still passed the reach check:
the regression GitHub #1271 removed. They now live in racereach.go, which
coverage does instrument, so the script also refuses that run. The package
is ruled with an empty allowlist, as production Go under testdata is.

The coverage half depends on how the toolchain lays out instrumented code, so
a later toolchain may stop hiding the plant. That weakens the check and never
fails a correct recipe.
The skills' citation rule is references/<file>.md#<key>, the form
skills_lint resolves; a bare anchor passes the lint whatever it names.
ParseProfiles ORs the counts of a set-mode profile and sums an atomic one's,
so the comment named a merge the gate's profiles no longer undergo.
The calls to mark are not dead code. write and read both run it, so
under coverage they share its counter, and that shared update is what
hides the race from a run with coverage merged in. Without the calls
the race is reported anyway, and scripts/race-reach.sh would pass a run
with coverage merged in.

The hiding also depends on where the calls sit: moving write's call
before its store, or read's after its load, brings the race back under
coverage. The comment on mark now says both, so neither reads as
something to tidy away.
@OmarAlJarrah

Copy link
Copy Markdown
Member

issue: the race list leaves out pipeline code that only parallel tests run concurrently, and every place that states the gap names internal/harness and internal/census alone.

concurrentDirs counts a non-test sync import or a goroutine statement, and deliberately not t.Parallel (racelist_test.go#L262-L268). But parallel tests do run morphic code on many goroutines at once, and plain -race finds races through them that the race list misses. On this head merged with main (3188471), 48 directories with parallel tests are off the list, 32 of them pipeline packages under ir/, pass and compilers/:

git grep -l 't\.Parallel()' -- '*_test.go' ':!**/testdata/**' | xargs -n1 dirname | sort -u |
  grep -vxE 'barrier|cmd/morphic|compilers/openapi/internal/load|engine(/.*)?|internal/ctxtest|internal/leakcheck|ir/irfacts' | wc -l

I planted one unsynchronized package-level int increment in each of two branches that only their own package's parallel tests run concurrently:

  • pass: inside checkPropIDRefs' loop over dangling prop references (validate_refs.go#L54). Validate's doc says concurrent calls are safe (validate.go#L41), and the engine runs it as a default pass, so this would be a production race whenever two runs validate broken documents at once.
  • ir/irverify: the ir/bigval-not-numeric branch of appendBigVal (bigval.go#L76).
var danglingProps int // above checkPropIDRefs

	for _, ref := range dangling {
		danglingProps++
Run, both plants in Result
go test -race ./pass ./ir/irverify reported in 3 of 3 runs of each package (1–3 reports in pass, 13–15 in irverify)
this PR's make race command passes, 0 reports
this PR's make coverage command on both packages passes, 100%
TestRaceList_NamesEveryConcurrentPackage passes
main's -race -covermode=atomic 0 reports in 3 of 3 runs of each package

So on these two, head is no worse than main: atomic coverage hid both before, as #1271 describes. But hidden races are what this PR sets out to fix, and outside the seven listed patterns the split leaves them as hidden as before, now because those packages never run under the detector. A plant at a function's entry cannot show this: one at the top of pass.validate was reported 291 times by this PR's make race, because the engine's concurrent tests reach every entry. The plant has to sit on a branch only the unlisted package's own tests take.

Where the gap is stated too narrowly:

  • scripts/race-packages.txt#L3: "A package belongs here when its code runs on more than one goroutine". pass and irverify meet that rule. L8-L11 then names two exceptions.
  • CLAUDE.md#L314: "Parallel tests alone do not count, so internal/harness and internal/census are off the list". architecture.md#L708 says the same.
  • racelist_test.go#L34-L38: an exemption is for a directory where "no morphic code runs on more than one goroutine", and compilers/openapi/e2e is exempt because its compile "runs on that goroutine alone". That package calls t.Parallel() 275 times.
  • The body: what the race run gives up is "race checking of tests that run morphic code on one goroutine".

Rules: CLAUDE.md's "Correctness comes first", and "A limitation that is deliberately out of scope must be stated in the code and in the PR body".

Direction, as a draft:

  1. Run make race over every package the engine runs (./ir/... ./pass ./compilers/... beside the current list), or over ./..., still skipping the two cancellation tests, and measure it on CI. The list and its archtest rule then shrink to the tooling deliberately left off.
  2. If that costs more than the split is worth, keep the list and state the gap as a class everywhere it is stated now: every package whose only concurrency is parallel tests is off the race run, pipeline packages included, so a race on a path only those tests take goes unreported, as it did under atomic coverage.

The first fixes the gap. The second makes it a stated trade.

@OmarAlJarrah

Copy link
Copy Markdown
Member

nit: two counts in the body are stale on this head.

  • "100% of 16,833 statements on this branch": the suite job on a5330ca (run 41486584623) prints Coverage gate passed: all 17947 statements covered.
  • "runs exactly the seven listed patterns (11 packages)": the same job's race step runs 12 packages (barrier, cmd/morphic, load, six under engine/..., ctxtest, leakcheck, irfacts).

Rule: CLAUDE.md's "Derive counts; never maintain them … If a claim about code must be written down, name the revision it was true at."

Fix: name the run and head each count was read from, or drop the counts. The gate already derives the first one on every run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ci: run coverage in set mode, and -race only where code is concurrent

2 participants