Skip to content

tests: add goleak leak detection and advisory race-detector CI (CON-179) - #4721

Open
prakhargarg105 wants to merge 10 commits into
mainfrom
con-179-goleak-race
Open

tests: add goleak leak detection and advisory race-detector CI (CON-179)#4721
prakhargarg105 wants to merge 10 commits into
mainfrom
con-179-goleak-race

Conversation

@prakhargarg105

@prakhargarg105 prakhargarg105 commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Part of CON-179 (test hardening). This PR adds two complementary checks: goroutine-leak detection inside the test suites of selected connector packages, and a race-detector CI job that starts out advisory and gains teeth package by package.

1. Goroutine-leak detection (goleak)

Adds go.uber.org/goleak and wires it into a package-level TestMain for four packages: internal/impl/mysql, internal/impl/postgresql, internal/impl/protobuf, and internal/impl/sql. After the last test in the package finishes, goleak fails the run if any goroutine started during the tests is still alive.

How it runs: TestMain is Go's standard per-package entry point, so this needs no CI wiring. It fires on every go test of these packages, which means every PR (via the existing test.yml unit suite) and every nightly integration run. A leak is a hard test failure.

Ignore policy: each TestMain carries only narrow, individually commented ignores for goroutines we cannot stop from test code — after review, the sole remaining example is the license service's hourly expiry-metric loop started by InjectTestService. Broad ignores are deliberately avoided; each entry names the library and the reason. (Earlier revisions ignored the prototransform SchemaWatcher poll loop and net/http persistConn goroutines; review showed those masked real component leaks, which are now fixed instead — see below.)

Real leaks fixed along the way:

  • protobuf (BSR mode): every processor construction started one prototransform.SchemaWatcher poll loop per module with no stop path — protobufProc.Close was a no-op — so stream restarts/config reloads accumulated goroutines for the life of the process. Watchers are now stopped from Close and on every construction-failure path (operator error after watchers start, partial multi-module failure, AwaitReady failure), and duplicate bsr module entries are rejected instead of silently orphaning the first module's watcher. Each path has a regression test guarded by goleak.
  • postgres_cdc: the snapshot→stream handoff parked a goroutine on snapshotAckWG.Wait(); a soft stop with unacked snapshot batches abandoned it forever. The WaitGroup is replaced by an atomic pending counter plus a drain-notification channel, so the handoff waits directly on drain-or-soft-stop with no helper goroutine, and the former ignore for it is removed. MarkSnapshotAcknowledged fires only when every snapshot batch settled via ack: a sticky nack flag (reachable with auto_replay_nacks: false, where raw nacks hit the ack function) leaves the slot unpromoted and restarts the stream so the snapshot re-runs, covered by a nack-barrier integration test; a double-settle now panics like the WaitGroup used to instead of silently disabling the drain signal.
  • Test-only fix: TestIntegrationPostgresSnapshotAckBarrier blocked a consumer goroutine on a context that benthos never cancels (consumer funcs receive context.Background()), which leaked the goroutine and the unacked stream behind it past the end of the test. The test now uses its own cancellable context for the simulated crash and explicitly stops the crashed stream with StopWithin. The protobuf mock BSR server also gets a proper Shutdown on cleanup.

All four packages were verified leak-clean on repeated local runs before the check was enabled, so this cannot fail a PR on a pre-existing condition.

2. Advisory race-detector CI job

New workflow .github/workflows/race_test.yml, plus a promotion list at .github/race-blocking-packages.txt.

How it runs: on every PR the job diffs against the merge-base and collects the internal/impl/<pkg> directories the PR touched (same auto-scoping as integration_test.yml, which keeps the 2-10x race-detector runtime cost bounded). If no connector package changed, the job skips entirely. Otherwise it runs go test -count=1 -race -shuffle=on -timeout 10m per touched package and writes a pass/fail table to the job summary. A workflow_dispatch trigger allows manual runs against any package by name.

Advisory-first design: only an actual data race (WARNING: DATA RACE in the test output) in a package listed in race-blocking-packages.txt fails the check. Everything else — races in unlisted packages, and build errors/flakes/timeouts anywhere (the ordinary unit-test workflow gates those) — produces a warning annotation and the check stays green. Summary rows are written incrementally and the job caps itself at 8 packages per run (excess logged as an advisory skip) so a job-level timeout kill can never turn a wide PR red without a race. That file starts empty, so on day one nothing can go red from this job. A package is promoted to blocking only after it holds a green -race baseline (repeated clean scoped runs plus a green advisory run in CI). The invariant this preserves: a blocking gate must never be able to fail a PR on a pre-existing race the PR did not introduce.

Supporting changes

  • CONTRIBUTING.md + taskfiles/test.yml: task test:unit-race now accepts package paths (task test:unit-race -- ./internal/impl/<component>/...) and mirrors the CI job's flags (-count=1 -race -shuffle=on), so the documented promotion baseline is executable and immune to the test cache; contributors are asked to run it scoped to packages they touch, with a pointer to the blocking-list semantics.
  • .claude/agents/tester.md: recipe for wiring goleak into a new package, including the ignore policy and the requirement to verify against a clean baseline before merging.
  • go.mod: adds go.uber.org/goleak v1.3.0 (test-only dependency).

🤖 Generated with Claude Code

prakhargarg105 and others added 2 commits August 12, 2026 09:25
… packages

Wire goleak.VerifyTestMain into internal/impl/{protobuf,sql,postgresql,mysql}
— the packages that shipped leak bugs (9caed49, 07c39cd) plus the two
flagship CDC connector packages — with narrow, commented per-package ignore
lists, each verified green on a clean baseline (unit x3, -race x2,
postgres/mysql integration suites under Docker).

Two real test-side leaks surfaced and fixed rather than ignored:
- protobuf: runMockBSRServer used bare http.Serve with no shutdown, leaking
  the accept loop and connection goroutines; now an http.Server closed via
  t.Cleanup.
- postgresql: TestIntegrationPostgresSnapshotAckBarrier's consumer waited on
  a context benthos never cancels (runConsumerFunc passes Background), and
  run 1 was never stopped because Stream.Run returns on ctx cancel without
  tearing the stream down; now selects on the test-owned context and stops
  the crashed stream explicitly.

Also documents the "adding goleak to a package" recipe in the tester agent
guide, per the R2 acceptance criteria.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r packages

Add race_test.yml: on every PR it runs go test -race against only the
internal/impl/<component> packages the PR touches, using the same
change-detection as integration_test.yml to bound the 2-10x race-detector
cost. Failures surface as warning annotations and a step-summary table, not
a red check — unless the package is listed in
.github/race-blocking-packages.txt, the promotion list for packages that
have earned a green -race baseline. The list starts empty so the gate can
never fail on a pre-existing race a PR didn't touch.

Also reference task test:unit-race in the CONTRIBUTING §6 pre-PR checklist,
per the R3 acceptance criteria.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread internal/impl/protobuf/main_test.go Outdated
…nores

Review on #4721 flagged that the goleak ignores in main_test.go were
masking a real production leak: multiModuleWatcher created each
prototransform.SchemaWatcher with context.Background() and no stop
path, so every BSR-backed protobuf processor leaked a poll goroutine
per module on each construction. The original justification ("no stop
hook") was wrong — SchemaWatcher exposes Stop().

- multiModuleWatcher gains close(), which stops every watcher.
- protobufProc.Close calls it, giving the poll loops a shutdown path.
- BSR tests now Close their processors via t.Cleanup.
- All three goleak ignores (prototransform poll loop and the two
  open-ended net/http persistConn entries) are removed; goleak now
  guards against regressions of this leak instead of hiding it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread internal/impl/protobuf/processor_protobuf.go
Follow-up review on #4721: wiring close() into protobufProc.Close fixed
the happy path, but watcher poll goroutines still leaked when
construction failed after watchers were started:

- newProtobuf: newMultiModuleWatcher succeeds, then
  strToProtobufBSROperator fails (e.g. message not resolvable in any
  BSR module) — the framework never gets a processor to Close.
- newMultiModuleWatcher: an error on a later module dropped
  already-running watchers for earlier modules.

Both paths now stop the accumulated watchers before returning the
error. Adds TestProtobufBSRConstructionFailureStopsWatchers covering
the unresolvable-message path; the package's goleak TestMain is what
verifies the watchers actually stop (removing the newProtobuf close()
call makes goleak fail with the prototransform poll goroutine).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread internal/impl/protobuf/multimodule_watcher.go
Comment thread internal/impl/postgresql/main_test.go Outdated
…ak review

Two more leak paths from review on #4721:

protobuf: prototransform.NewSchemaWatcher starts its poll loop
immediately, so when AwaitReady failed (unreachable BSR URL, rejected
API key) newSchemaWatcher returned without Stop() and the watcher never
reached bsrClients — no cleanup path could touch it. Stop the watcher
before returning the error.

postgres_cdc: the snapshot->stream handoff parked a goroutine on
snapshotAckWG.Wait(); a soft stop with unacked snapshot batches
abandoned it for the life of the process (WaitGroup.Wait cannot be
interrupted). Replace the WaitGroup with an atomic pending counter plus
a buffered drain-notification channel so the handoff waits directly on
drain-or-soft-stop with no helper goroutine at all. Semantics are
unchanged: block until acks drain or soft stop, and only promote the
replication slot when actually drained.

The processStream.func3 goleak ignore is removed from the postgresql
TestMain; TestIntegrationPostgresSnapshotAckBarrier (which triggers
exactly the previously-leaking path) passes under the stricter check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread internal/impl/protobuf/multimodule_watcher.go
prakhargarg105 and others added 3 commits August 24, 2026 11:41
…paths

Review on #4721 noted two of the leak fixes had no test exercising
them, so the package goleak gate could not catch a regression:

- TestProtobufBSRPartialModuleFailureStopsWatchers: two-module config
  where the first module's watcher starts against the mock BSR server
  and the second fails module parsing, covering the deferred close()
  guard in newMultiModuleWatcher.
- TestProtobufBSRAwaitReadyFailureStopsWatcher: unreachable BSR
  endpoint, covering the watcher.Stop() in newSchemaWatcher's
  AwaitReady error branch. watcherTimeout becomes a var so the test
  can shorten it to 250ms — prototransform retries until the
  constructor context dies, so with the const the failure always took
  the full 10s.

Both tests verified negatively: removing either fix makes the package
run fail with the corresponding leaked prototransform goroutine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…up-module watcher orphaning

From a high-effort review pass over this branch:

postgres_cdc: after the snapshot handoff was abandoned on soft stop,
the trailing counter re-check could still call MarkSnapshotAcknowledged
when teardown nacks drained the counter (reachable with
auto_replay_nacks: false, where nacks hit the raw ackFn), promoting the
replication slot over rows never durably delivered and skipping the
snapshot on restart. Mark is now gated on a drain observed via the
drain channel, never the soft-stop exit. Also restore the WaitGroup's
lost double-settle safety net: snapshotAckDone panics if the counter
goes negative instead of silently disabling the drain signal forever.

protobuf: two bsr entries naming the same module silently overwrote the
first watcher in bsrClients, leaving its running poll goroutine
unreachable by close(). Duplicate modules are now a config error (the
construction-failure guard stops the earlier watchers); covered by
TestProtobufBSRDuplicateModuleStopsWatchers, negatively verified
against the goleak gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings against the new advisory race job:

- Classify failures by grepping output for 'WARNING: DATA RACE': only
  an actual race in a blocking-listed package can exit 1. Build errors,
  flakes, and per-package timeouts are advisory warnings even for
  blocking packages (the ordinary unit-test workflow gates those).
- Write each package's summary row incrementally and cap the run at
  MAX_PACKAGES=8 with a logged advisory truncation, with job
  timeout-minutes raised above the composed worst case, so a job-level
  kill can never turn a wide PR red without a race.
- Pass github.base_ref via env and quote it (git ref names may contain
  shell metacharacters), and split merge-base/diff into checked
  statements under pipefail so git failures fail the step loudly
  instead of silently scoping to zero packages. integration_test.yml
  carries the same two inherited flaws; fixing it (or extracting a
  shared composite action) is left as a follow-up to keep this PR from
  touching working CI.
- test:unit-race now accepts package paths (task test:unit-race --
  ./internal/impl/foo/) and mirrors the CI flags (-count=1 -race
  -shuffle=on -timeout 10m) so the documented promotion baseline is
  actually executable and immune to the test cache; CONTRIBUTING.md
  shows the scoped invocation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread .github/workflows/race_test.yml Outdated
Review on #4721: tr '\n' ' ' turned the blank-input case into a lone
space, which survives command substitution, so the 'filters != empty'
guards never skipped — every docs-only PR still ran checkout with
fetch-depth 0, installed Go, and posted an empty results table. Join
the package list with xargs instead, which emits an empty string on
blank input (verified locally for both the empty and real-diff cases).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread internal/impl/postgresql/input_pg_stream.go
…napshot barrier

Follow-up review on #4721: gating MarkSnapshotAcknowledged on the drain
channel still conflated acks with nacks — ackFn discarded its error, so
with auto_replay_nacks: false a downstream that permanently rejects the
final snapshot batch drained the counter, fired the drain, and promoted
the slot over rows never durably delivered (deterministically, no soft
stop needed). The soft-stop-vs-drain select race had the same hole.

ackFn now records a sticky snapshotAckFailed flag before the counter
decrement, and the handoff promotes only on a clean drain; a nack drain
logs and soft-stops so the stream restarts and re-runs the snapshot,
matching the neighboring flush-failure paths. The flag resets on
Connect (a fresh snapshot re-reads the rows); the pending counter
deliberately does not (late cross-epoch settles still decrement it).

Adds TestIntegrationPostgresSnapshotNackBarrier: auto_replay_nacks
false, the whole snapshot in one batch, consumer nacks it — asserts the
slot is never promoted and the snapshot re-runs on restart. Negatively
verified: with the fix reverted the test fails with the slot promoted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant