Skip to content

ci: run coverage in set mode and -race in a run of its own - #1311

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

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

Conversation

@fuad-daoud

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

Copy link
Copy Markdown
Collaborator

Summary

In short: CI's slowest step ran every test under the race detector with atomic coverage, which is slow and can hide a data race. This splits it: coverage runs without -race, -race runs on each pull request over the packages that run code concurrently, and the whole pipeline is raced after each push to main.

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, and races the whole pipeline after merge.

  • make coverage runs go test ./... -covermode=set without -race. cmd/morphic-covergate reads only whether a block ran, so its verdict is unchanged. The gate prints the statement count it judged on every run.
  • make race, on every pull request, runs -race alone on the patterns in scripts/race-packages.txt: the packages whose production code imports sync or sync/atomic, those with a file that starts a goroutine (a go statement, or a selector named Go, TryGo or AfterFunc), and, outside the pipeline, those whose tests run in parallel, bar each raceExempt and racePipelineOnly entry. It skips TestLoad_ACancelAtAnyCheckEndsTheLoadCleanly and TestLoad_DoesNextToNothingOnceItRefusesACancel, which cancel a load at every check and cost most of load's race time; make race-pipeline still races them, and they run at full size in make coverage. Among its tests is TestEngine_ConcurrentRunSharesOneEngine, which compiles the conformance corpus through one engine on 8 goroutines.
  • make race-pipeline adds every package under the trees in scripts/race-pipeline-roots.txt (compilers, ir, pass), whose parallel tests run pipeline code on many goroutines. The suite job runs it on each push to main, after the cache save, so it adds nothing to a pull request's wait and a race it finds costs no pull request its cache. make gate leaves it out, and every statement of what make gate and CI run says so.
  • Both targets prove they reach: 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 in RACE_TEST fail the gate: dropping -race, and merging coverage back into the race run.
  • The lists cannot drift. TestRaceList_NamesEveryConcurrentPackage reads the same two files the Makefile does. It fails when a package with one of the signals is on no list, when an entry or exemption is stale, when a package under a root is exempt from both runs, when a subtree entry (./x/...) lies under a root, which make race names package by package, and when a package is both listed and deferred. A package under a root with a signal of its own that make race leaves to the push run is a racePipelineOnly entry with its reason; one elsewhere that neither run covers is a raceExempt entry with its reason; and an entry of either table with a blank reason fails the rule.
  • 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:

  • A race that only a pipeline package's own tests reach is found after merge, by make race-pipeline on each push to main, and not on the pull request. A reviewer's plant on such a path, in ir/irverify/ids.go's primIDViolation, is reported by main's atomic run 7 of 9 times, by make race 0 of 3, and by make race-pipeline on its run. The narrow list keeps the pull request's race step at 67–110s on this branch's Actions runs; the cost of the wide one is below.
  • make race skips two load tests that cancel a load at every check. Four cancel returns only they reach (load.go:248, bindings.go:145, overlays.go:82, phases.go:174) are raced by make race-pipeline alone, after merge, and run at full size in make coverage.
  • Race checking of tooling outside the pipeline whose one signal is a parallel test: each such package is a raceExempt entry in internal/archtest/racelist_test.go with what its tests run, internal/harness and internal/census (ci: run coverage in set mode, and -race only where code is concurrent #1271) and internal/archtest (its go/types loads race, test(archtest): the recursion pin's load can trip a go/types data race #855) among them. Every test still runs, without -race, in make coverage.
  • 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, every pull request"]
    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: three runs"]
    direction TB
    a1["make coverage, every pull request<br/>go test ./... -covermode=set"] --> a2["100% gate, same verdict"]
    a3["make race, every pull request<br/>-race on scripts/race-packages.txt"] --> a4["packages that run code concurrently<br/>plus a planted race that must be reported"]
    a5["make race-pipeline, each push to main<br/>adds compilers, ir and pass"] --> a6["races only the pipeline's own<br/>parallel tests reach, after merge"]
    a7["TestRaceList_NamesEveryConcurrentPackage"] -. "holds both lists to the tree" .-> a3
  end
  before ~~~ after
Loading

What it brings: coverage no longer silences the race detector, a pull request's race step stays at the narrow list's size, and the whole pipeline is raced on every merge, with each run proven to report a planted race.

Test plan

  • make gate passed on 16b38e3c, with main at d3ce8e8f merged in, on 8 pinned cores. Later commits change make race's skip, the rule's test, comments and documents, including a reason check on both exemption tables, the comments on childTimeout and probeTimeout, and the helper both the rule and the goroutine-leak check read, startsGoroutine, which now also counts time.AfterFunc, context.AfterFunc, TryGo and a Go method value, each beside any other selector; go build ./..., go test ./internal/archtest ./internal/harness ./compilers/openapi/internal/canary and make vet lint lint-planted pass on them, and skills_lint.py is clean. CI on this head, with main at 2a0996a6 merged in, runs the gate.

  • The workflow's coverage and race steps ran on Actions in a test pull request on a fork (check: #1271 workflow on CI fuad-daoud/morphic#1): runs 41360252466 and 41365106637, on 5f5b8198 and ce531939. The push-only step has not run on Actions: actionlint is clean on it, and make -n race-pipeline races the list's patterns plus the three roots, then runs the reach check.

  • Each target's reach: removing -race from RACE_TEST, adding -covermode=atomic to it, or making the plant race-free, each fails scripts/race-reach.sh.

  • Plants, go clean -testcache before each run, in a throwaway copy:

    Plant make race make race-pipeline
    ir/irverify/bigval.go, the bigval-not-numeric arm of appendBigVal 0 of 3 runs 3 of 3 (10–13 reports)
    ir/irverify/ids.go, first in primIDViolation 0 of 3 reported (2 reports)

    main's combined run reports the first 0 of 3 and the second 7 of 9.

  • The race-list rule reddens under each planted mutant: a root dropped or narrowed, ./barrier or ./internal/leakcheck dropped from the list, a stale raceExempt or racePipelineOnly entry, a pipeline package moved to raceExempt, a package both listed and deferred, the wide list (./ir/..., ./pass, ./compilers/... beside these) restored, with and without the deferrals that otherwise redden it, the parallel-test signal counted under the roots or never counted, a blank reason in either table, and one more for each of the rule's other branches. A comment edit to either file survives.

  • TestStartsGoroutine_RecognizesEverySpelling fails 11 of its 17 rows on the helper that read only go statements and .Go(…) calls; a package whose production code calls time.AfterFunc reddens both TestRaceList_NamesEveryConcurrentPackage and TestGoroutineLeak_EveryGoStatementHasALeakCheck. No file in the tree reads differently under the wider match.

  • Each racePipelineOnly reason was read against every goroutine site in its package, and a planted race in Compile is reported from compilers/openapi/e2e's parallel tests under -race, which is why that package's reason defers them to the push run rather than calling them sequential.

  • Cost, go clean -testcache before each run, 4 pinned cores of a loaded AMD Ryzen 7 5800X: make race 70–78s, make race-pipeline 269–361s with load's two cancel tests still skipped, its slowest package compilers/openapi/e2e at 175–287s against -timeout 300s. With them in, load alone takes 129–191s under -race. The command make race-pipeline runs, on 4 busy cores, timed out at 300s in load and compilers/openapi/e2e at a load average falling from 79 to 55, and with the timeout raised took 345s and 289s at a load average of about 25. On Actions, main's combined run at 0d09a96d, which holds those two tests under -race, took load to 143s and compilers/openapi/e2e to 92s, so the push run is expected to clear the limit; it has not run there. On Actions the 48-package race step took 327s with the race build cached.

  • After merge: read that the first push to main ran make race-pipeline, its time against the suite job's 30 minutes, and load's and compilers/openapi/e2e's against -timeout 300s. A -timeout panic is test: make coverage's 300s timeout is under twice what ir and load take #1248's, a child read as hung test: child-process probes read a child slowed by load as hung #1010's; one naming a race is the step working. test: move fuzz targets to packages that link no test tooling #1381 adds ./internal/ircases and ./internal/testspec to scripts/race-packages.txt when it merges this, as their parallel tests call for; if it lands first, this branch adds them when it merges main.

  • At merge, set the squash body by hand: a merge commit on this branch, 16b38e3c, carries attribution trailers that a default squash would paste onto main.

`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.
callsParallel matching any no-argument method and raceCovers ignoring the
subtree suffix both stayed green. The pipeline-roots test now plants a test
whose only call is t.Helper() and an entry without "/..." beside a child
directory, so each mutant reddens it.

Also drops raceEntry's dir == "" check, which filepath.IsLocal already makes,
reuses writeDocFile in writeParallelTest, and returns a found flag instead of
"" from pipelineRootOf and importsSync.
An entry such as ./ir/... passed the race rule whenever it covered one package with a signal and no racePipelineOnly entry lay under it, so the list could grow from a dozen packages to every package under a root without the rule noticing, while the same widening spelled package by package was refused. A subtree entry under a root is now reported, and a plant pins it.
Three raceExempt reasons named work their tests do not do: the
covergate's tallyOf test builds its profiles in memory, the decls tests
share one fixture, and the exectest suite never starts a child. Each
reason now says what the parallel tests call and over what.
The comment on RACE_TEST named compilers/openapi/e2e as the package
that takes most of the 300s timeout on four busy cores. Since the two
cancel tests joined race-pipeline, internal/load takes as long and has
been seen past the limit as well, so the comment names both.
startsGoroutine, which feeds both the leak-check guard and the race-list
rule, read a go statement and a single-argument .Go(...) call only. A
package starting a goroutine through time.AfterFunc, context.AfterFunc,
TryGo, or a Go method taken as a value joined no race list and drew no
entry, so a race in it was found by neither run.

Match a go statement or a selector named Go, TryGo or AfterFunc, called
or passed as a value. The match stays syntactic, so a false positive
costs one extra TestMain and a miss is what the guard prevents. No file
in the tree differs between the two readings. Add a table test over
every spelling, and state the wider reading where CLAUDE.md and
docs/architecture.md describe the rule.
startsGoroutine assigned its flag on every selector, so a Go, TryGo or
AfterFunc selector was forgotten as soon as the next selector in the
walk was not one: g.Go(s.run) and time.AfterFunc(time.Second, f) read
as starting no goroutine, which left the leak check and both race
tables blind to them. The flag now only rises.

Three rows join the spelling table, each with a sibling selector the
earlier rows lacked. The method value row also regressed against the
check this one replaced.
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