17 Commits

Author SHA1 Message Date
Christopher Homberger
f4b8e7605c update modules 2026-02-20 13:10:32 +01:00
Christopher Homberger
b0726c4423 Wait for RunDaemon Acknowledge
* Always skip reporting job result from RunDaemon
2026-02-20 13:01:12 +01:00
silverwind
97c1320234 reduce diff 2026-02-19 04:18:37 +01:00
silverwind
c1c36d1c2d undo whitespace change, add comments 2026-02-19 04:14:40 +01:00
silverwind
3e139b7f09 fix: prevent RunDaemon from sending completed state before Close sends final logs
Split ReportState into a public method that skips when closed (used by
RunDaemon) and a private reportState that always sends (used by Close).
This prevents the server from deleting ephemeral runners before final
logs are uploaded. Add test reproducing the exact interleaving from #793.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:05:54 +01:00
silverwind
5e139031c6 fmt 2026-02-19 03:25:58 +01:00
silverwind
64ab5fdd51 fix: use stateMu instead of closedM to protect r.closed
Remove the dedicated closedM mutex and use stateMu instead, since
closed is part of the reporter state. RunDaemon reads r.closed under
stateMu.RLock, Close sets it under the existing stateMu.Lock block.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 03:20:13 +01:00
silverwind
39cef65b52 fix: hold closedM for both ReportLog and ReportState in RunDaemon
After Close() sets r.closed, the daemon should make zero network calls.
Move ReportLog(false) under the same closedM guard as ReportState() so
the daemon skips both when closed. This also simplifies RunDaemon by
using a single lock/unlock pair.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 03:13:55 +01:00
silverwind
a4a6e291d5 fix: protect all r.closed accesses, add race test, enable -race in CI
The initial `if r.closed` guard in RunDaemon() was still reading
r.closed without holding closedM, causing a data race with Close().
Wrap it with closedM to match the other access sites.

Add TestReporter_RunDaemonClose_Race which exercises RunDaemon() and
Close() concurrently — the exact scenario from #793. Without the fix,
`go test -race` reliably detects the data race.

Enable the -race flag in `make test` so CI catches data races.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 02:54:45 +01:00
rmawatson
5591f0a546 fixes #793 2026-02-19 01:00:53 +00:00
silverwind
0f7efae806 chore(deps): update workflow action versions (#792)
## Summary
- `actions/checkout`: v4/v5 → v6
- `actions/setup-go`: v5 → v6
- `docker/build-push-action`: v5 → v6

All other actions (`goreleaser/goreleaser-action@v6`, `docker/setup-qemu-action@v3`, `docker/setup-buildx-action@v3`, `docker/login-action@v3`, `crazy-max/ghaction-import-gpg@v6`) were already at their latest major versions.

No breaking changes affect the current workflow configurations. The main changes in the updated actions are Node.js 24 runtime upgrades and minor feature additions.

Reviewed-on: https://gitea.com/gitea/act_runner/pulls/792
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-02-18 06:05:34 +00:00
silverwind
aab249000c chore(deps): update act to v0.261.8 (#791)
## Summary
- Update `gitea.com/gitea/act` from pseudo-version `v0.261.7-0.20251202193638-5417d3ac6742` to `v0.261.8`
- Update yaml import in `workflow.go` from `gopkg.in/yaml.v3` to `go.yaml.in/yaml/v4` to match act's yaml v4 migration
- Add tests to verify yaml/v4 upgrade works correctly

## Changes included in act v0.261.8
- Recover from panics in parallel executor workers ([gitea/act#153](https://gitea.com/gitea/act/pulls/153))
- Fix max-parallel support for matrix jobs ([gitea/act#150](https://gitea.com/gitea/act/pulls/150))
- Fix yaml with prefixed newline broken setjob + yaml v4 ([gitea/act#144](https://gitea.com/gitea/act/pulls/144))
- Fixed typo ([gitea/act#151](https://gitea.com/gitea/act/pulls/151))

## Tests added
- **`Test_generateWorkflow`**: 7 new cases (was 1), covering: no needs, needs as list, workflow env/triggers, container+services, matrix strategy, special YAML characters, and invalid YAML error handling
- **`Test_yamlV4NodeRoundTrip`**: Directly exercises `go.yaml.in/yaml/v4` — `yaml.Node` construction, marshal/unmarshal round-trip, and node kind constants

Fixes: https://gitea.com/gitea/act_runner/issues/371
Fixes: https://gitea.com/gitea/act_runner/issues/772
Reviewed-on: https://gitea.com/gitea/act_runner/pulls/791
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-02-18 05:37:21 +00:00
stecklars
c82077e6b5 examples: improve DIND rootless network performance (#786)
## Summary
- Add `DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns` and `DOCKERD_ROOTLESS_ROOTLESSKIT_MTU=65520` to the DIND docker-compose example
- The `docker:dind-rootless` base image defaults to vpnkit as the network driver, which has substantially lower throughput than slirp4netns

## The problem

I noticed that pulling containers as well as downloading data within the container when running act_runner as DIND was very slow (see Ookla speedtest results in the following). While analysing the issue, I found that this was caused by the usage of vpnkit.

The `docker:dind-rootless` base image defaults to vpnkit as the network driver. slirp4netns was [added as an opt-in option](https://github.com/docker-library/docker/pull/543) and must be explicitly enabled via `DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns`.

This means anyone following the current DIND example gets vpnkit, which has significantly lower network throughput. This affects **all** network operations in the container — image pulls, package installs, and CI tasks.

Per the [rootlesskit iperf3 benchmarks](https://github.com/rootless-containers/rootlesskit/blob/master/docs/network.md):

| Driver | MTU 1500 | MTU 65520 |
|--------|----------|-----------|
| **vpnkit** | 0.60 Gbps | not supported |
| **slirp4netns** | 1.06 Gbps | 7.55 Gbps |

## Real-world benchmark results (Ookla speedtest, same server)

| | Download | Upload |
|---|---|---|
| **Default (vpnkit)** | ~130 Mbps | ~126 Mbps |
| **slirp4netns + MTU 65520** | ~958 Mbps | ~462 Mbps |

## References
- [docker-library/docker#543](https://github.com/docker-library/docker/pull/543) — added slirp4netns to dind-rootless as opt-in (vpnkit remains default)
- [rootlesskit network docs](https://github.com/rootless-containers/rootlesskit/blob/master/docs/network.md) — iperf3 benchmarks

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/act_runner/pulls/786
Reviewed-by: silverwind <silverwind@noreply.gitea.com>
Co-authored-by: stecklars <stecklars@noreply.gitea.com>
Co-committed-by: stecklars <stecklars@noreply.gitea.com>
2026-02-16 07:56:45 +00:00
silverwind
9f91fdd0eb chore(deps): bump Go version to 1.26 in Dockerfile and Makefile (#788)
## Summary
- Bump Dockerfile builder image from `golang:1.24-alpine` to `golang:1.26-alpine`
- Bump Makefile `XGO_VERSION` from `go-1.24.x` to `go-1.26.x`

These were missed in #787 which bumped `go.mod` to Go 1.26.0, causing CI build failures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: https://gitea.com/gitea/act_runner/pulls/788
Reviewed-by: ChristopherHX <christopherhx@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-02-14 10:32:16 +00:00
silverwind
7a7a5d9051 chore(deps): bump Go version to 1.26.0 (#787)
## Summary
- Bumps Go version from 1.24.0 (toolchain go1.24.11) to 1.26.0
- Fixes CI `govulncheck` failures caused by three standard library vulnerabilities in go1.24.11:
  - GO-2026-4341: Memory exhaustion in `net/url` query parameter parsing
  - GO-2026-4340: Handshake messages at incorrect encryption level in `crypto/tls`
  - GO-2026-4337: Unexpected session resumption in `crypto/tls`

## Test plan
- [x] `make vet` passes
- [x] `make build` passes
- [x] `make test` passes (includes `govulncheck` and all unit tests)

Reviewed-on: https://gitea.com/gitea/act_runner/pulls/787
Reviewed-by: ChristopherHX <christopherhx@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-02-14 10:19:45 +00:00
mkesper
90d11b8692 Remove duplicate assignment of DIST (#777)
The assignment already happens at line 1.

Signed-off-by: mkesper <mkesper@noreply.gitea.com>

Reviewed-on: https://gitea.com/gitea/act_runner/pulls/777
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: mkesper <mkesper@noreply.gitea.com>
Co-committed-by: mkesper <mkesper@noreply.gitea.com>
2025-12-26 00:38:38 +00:00
Christopher Homberger
c4b57fbcb2 chore(deps): upgrade dependencies (#775)
CI uses latest go 24, we may need a cron job after go updates.

Closes https://gitea.com/gitea/act_runner/issues/774

Reviewed-on: https://gitea.com/gitea/act_runner/pulls/775
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Christopher Homberger <christopher.homberger@web.de>
Co-committed-by: Christopher Homberger <christopher.homberger@web.de>
2025-12-20 23:35:15 +00:00
12 changed files with 452 additions and 60 deletions

View File

@@ -17,10 +17,10 @@ jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v5
- uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
- name: goreleaser
@@ -52,7 +52,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0 # all history for all branches and tags

View File

@@ -9,10 +9,10 @@ jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0 # all history for all branches and tags
- uses: actions/setup-go@v5
- uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
- name: Import GPG key
@@ -46,7 +46,7 @@ jobs:
DOCKER_LATEST: latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0 # all history for all branches and tags
@@ -69,7 +69,7 @@ jobs:
echo REPO_VERSION=${GITHUB_REF_NAME#v} >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
@@ -83,7 +83,7 @@ jobs:
${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}
- name: Build and push dind
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
@@ -97,7 +97,7 @@ jobs:
${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}-dind
- name: Build and push dind-rootless
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile

View File

@@ -8,8 +8,8 @@ jobs:
name: check and test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: vet checks

View File

@@ -1,7 +1,7 @@
### BUILDER STAGE
#
#
FROM golang:1.24-alpine AS builder
FROM golang:1.26-alpine AS builder
# Do not remove `git` here, it is required for getting runner version when executing `make build`
RUN apk add --no-cache make git

View File

@@ -1,13 +1,12 @@
DIST := dist
EXECUTABLE := act_runner
GOFMT ?= gofumpt -l
DIST := dist
DIST_DIRS := $(DIST)/binaries $(DIST)/release
GO ?= go
SHASUM ?= shasum -a 256
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
XGO_PACKAGE ?= src.techknowlogick.com/xgo@latest
XGO_VERSION := go-1.24.x
XGO_VERSION := go-1.26.x
GXZ_PAGAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.10
LINUX_ARCHS ?= linux/amd64,linux/arm64
@@ -113,7 +112,7 @@ security-check: deps-tools
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./...
test: fmt-check security-check
@$(GO) test -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
@$(GO) test -race -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
.PHONY: vet
vet:

View File

@@ -53,6 +53,9 @@
environment:
- GITEA_INSTANCE_URL=<instance url>
- DOCKER_HOST=unix:///var/run/user/1000/docker.sock
# Use slirp4netns instead of vpnkit for significantly better network throughput.
- DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns
- DOCKERD_ROOTLESS_ROOTLESSKIT_MTU=65520
# When using Docker Secrets, it's also possible to use
# GITEA_RUNNER_REGISTRATION_TOKEN_FILE to pass the location.
# The env var takes precedence.

20
go.mod
View File

@@ -1,8 +1,6 @@
module gitea.com/gitea/act_runner
go 1.24.0
toolchain go1.24.11
go 1.26.0
require (
code.gitea.io/actions-proto-go v0.4.1
@@ -16,13 +14,15 @@ require (
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.11.1
golang.org/x/term v0.36.0
golang.org/x/term v0.37.0
golang.org/x/time v0.12.0
google.golang.org/protobuf v1.35.2
gopkg.in/yaml.v3 v3.0.1
gotest.tools/v3 v3.5.1
)
require go.yaml.in/yaml/v4 v4.0.0-rc.2
require (
cyphar.com/go-pathrs v0.2.1 // indirect
dario.cat/mergo v1.0.0 // indirect
@@ -47,7 +47,7 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-git/go-git/v5 v5.12.0 // indirect
github.com/go-git/go-git/v5 v5.16.5 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gobwas/glob v0.2.3 // indirect
@@ -93,18 +93,16 @@ require (
go.opentelemetry.io/otel v1.28.0 // indirect
go.opentelemetry.io/otel/metric v1.28.0 // indirect
go.opentelemetry.io/otel/trace v1.28.0 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/tools v0.23.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
replace github.com/nektos/act => gitea.com/gitea/act v0.261.7-0.20251202193638-5417d3ac6742
replace github.com/go-git/go-git/v5 => github.com/go-git/go-git/v5 v5.16.2
replace github.com/nektos/act => gitea.com/gitea/act v0.261.8
// Remove after
replace github.com/distribution/reference v0.6.0 => github.com/distribution/reference v0.5.0

40
go.sum
View File

@@ -8,8 +8,8 @@ cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8=
cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
gitea.com/gitea/act v0.261.7-0.20251202193638-5417d3ac6742 h1:ulcquQluJbmNASkh6ina70LvcHEa9eWYfQ+DeAZ0VEE=
gitea.com/gitea/act v0.261.7-0.20251202193638-5417d3ac6742/go.mod h1:Pg5C9kQY1CEA3QjthjhlrqOC/QOT5NyWNjOjRHw23Ok=
gitea.com/gitea/act v0.261.8 h1:rUWB5GOZOubfe2VteKb7XP3HRIbcW3UUmfh7bVAgQcA=
gitea.com/gitea/act v0.261.8/go.mod h1:lTp4136rwbZiZS3ZVQeHCvd4qRAZ7LYeiRBqOSdMY/4=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
@@ -51,8 +51,6 @@ github.com/docker/cli v25.0.3+incompatible h1:KLeNs7zws74oFuVhgZQ5ONGZiXUUdgsdy6
github.com/docker/cli v25.0.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v25.0.6+incompatible h1:5cPwbwriIcsua2REJe8HqQV+6WlWc1byg2QSXzBxBGg=
github.com/docker/docker v25.0.6+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v25.0.13+incompatible h1:YeBrkUd3q0ZoRDNoEzuopwCLU+uD8GZahDHwBdsTnkU=
github.com/docker/docker v25.0.13+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo=
@@ -77,8 +75,8 @@ github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UN
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM=
github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s=
github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -229,14 +227,14 @@ go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -248,10 +246,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -271,20 +267,16 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -11,7 +11,7 @@ import (
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/nektos/act/pkg/model"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
func generateWorkflow(task *runnerv1.Task) (*model.Workflow, string, error) {

View File

@@ -9,6 +9,7 @@ import (
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/nektos/act/pkg/model"
"github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4"
"gotest.tools/v3/assert"
)
@@ -62,13 +63,267 @@ jobs:
want1: "job9",
wantErr: false,
},
{
name: "no needs",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte(`
name: Simple workflow
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: echo "hello"
`),
Needs: map[string]*runnerv1.TaskNeed{},
},
},
assert: func(t *testing.T, wf *model.Workflow) {
job := wf.GetJob("test")
assert.DeepEqual(t, job.Needs(), []string{})
assert.Equal(t, len(job.Steps), 2)
},
want1: "test",
wantErr: false,
},
{
name: "needs list",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte(`
name: Workflow with list needs
on: push
jobs:
deploy:
needs: [build, test, lint]
runs-on: ubuntu-latest
steps:
- run: echo "deploying"
`),
Needs: map[string]*runnerv1.TaskNeed{
"build": {
Outputs: map[string]string{},
Result: runnerv1.Result_RESULT_SUCCESS,
},
"test": {
Outputs: map[string]string{
"coverage": "80%",
},
Result: runnerv1.Result_RESULT_SUCCESS,
},
"lint": {
Outputs: map[string]string{},
Result: runnerv1.Result_RESULT_FAILURE,
},
},
},
},
assert: func(t *testing.T, wf *model.Workflow) {
job := wf.GetJob("deploy")
needs := job.Needs()
assert.DeepEqual(t, needs, []string{"build", "lint", "test"})
assert.Equal(t, wf.Jobs["test"].Outputs["coverage"], "80%")
assert.Equal(t, wf.Jobs["lint"].Result, "failure")
},
want1: "deploy",
wantErr: false,
},
{
name: "workflow env and defaults",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte(`
name: Complex workflow
on:
push:
branches: [main, develop]
pull_request:
types: [opened, synchronize]
env:
NODE_ENV: production
CI: "true"
jobs:
build:
runs-on: ubuntu-latest
env:
BUILD_TYPE: release
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: "18"
- run: npm ci
- run: npm run build
`),
Needs: map[string]*runnerv1.TaskNeed{},
},
},
assert: func(t *testing.T, wf *model.Workflow) {
assert.Equal(t, wf.Name, "Complex workflow")
assert.Equal(t, wf.Env["NODE_ENV"], "production")
assert.Equal(t, wf.Env["CI"], "true")
job := wf.GetJob("build")
assert.Equal(t, len(job.Steps), 4)
},
want1: "build",
wantErr: false,
},
{
name: "job with container and services",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte(`
name: Integration tests
on: push
jobs:
integration:
runs-on: ubuntu-latest
container:
image: node:18
services:
postgres:
image: postgres:15
steps:
- uses: actions/checkout@v3
- run: npm test
`),
Needs: map[string]*runnerv1.TaskNeed{},
},
},
assert: func(t *testing.T, wf *model.Workflow) {
job := wf.GetJob("integration")
container := job.Container()
assert.Equal(t, container.Image, "node:18")
assert.Equal(t, job.Services["postgres"].Image, "postgres:15")
},
want1: "integration",
wantErr: false,
},
{
name: "job with matrix strategy",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte(`
name: Matrix build
on: push
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ["1.21", "1.22"]
steps:
- uses: actions/checkout@v3
- run: go test ./...
`),
Needs: map[string]*runnerv1.TaskNeed{},
},
},
assert: func(t *testing.T, wf *model.Workflow) {
job := wf.GetJob("test")
matrixes, err := job.GetMatrixes()
require.NoError(t, err)
assert.Equal(t, len(matrixes), 2)
},
want1: "test",
wantErr: false,
},
{
name: "special yaml characters in values",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte("name: \"Special: characters & test\"\non: push\n\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: 'echo \"hello: world\"'\n - run: 'echo \"quotes & ampersands\"'\n - run: |\n echo \"multiline\"\n echo \"script\"\n"),
Needs: map[string]*runnerv1.TaskNeed{},
},
},
assert: func(t *testing.T, wf *model.Workflow) {
assert.Equal(t, wf.Name, "Special: characters & test")
job := wf.GetJob("test")
assert.Equal(t, len(job.Steps), 3)
},
want1: "test",
wantErr: false,
},
{
name: "invalid yaml",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte(`
name: Bad workflow
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo "ok"
bad-indent: true
`),
Needs: map[string]*runnerv1.TaskNeed{},
},
},
assert: nil,
want1: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, got1, err := generateWorkflow(tt.args.task)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
tt.assert(t, got)
assert.Equal(t, got1, tt.want1)
})
}
}
func Test_yamlV4NodeRoundTrip(t *testing.T) {
t.Run("marshal sequence node", func(t *testing.T) {
node := yaml.Node{
Kind: yaml.SequenceNode,
Content: []*yaml.Node{
{Kind: yaml.ScalarNode, Value: "a"},
{Kind: yaml.ScalarNode, Value: "b"},
{Kind: yaml.ScalarNode, Value: "c"},
},
}
out, err := yaml.Marshal(&node)
require.NoError(t, err)
assert.Equal(t, string(out), "- a\n- b\n- c\n")
})
t.Run("unmarshal and re-marshal workflow", func(t *testing.T) {
input := []byte("name: test\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hello\n")
var wf map[string]interface{}
err := yaml.Unmarshal(input, &wf)
require.NoError(t, err)
assert.Equal(t, wf["name"], "test")
out, err := yaml.Marshal(wf)
require.NoError(t, err)
var wf2 map[string]interface{}
err = yaml.Unmarshal(out, &wf2)
require.NoError(t, err)
assert.Equal(t, wf2["name"], "test")
})
t.Run("node kind constants", func(t *testing.T) {
// Verify yaml/v4 node kind constants are usable (same API as v3)
require.NotEqual(t, yaml.ScalarNode, yaml.SequenceNode)
require.NotEqual(t, yaml.SequenceNode, yaml.MappingNode)
})
}

View File

@@ -37,6 +37,7 @@ type Reporter struct {
state *runnerv1.TaskState
stateMu sync.RWMutex
outputs sync.Map
daemon chan struct{}
debugOutputEnabled bool
stopCommandEndToken string
@@ -63,6 +64,7 @@ func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.C
state: &runnerv1.TaskState{
Id: task.Id,
},
daemon: make(chan struct{}),
}
if task.Secrets["ACTIONS_STEP_DEBUG"] == "true" {
@@ -109,6 +111,7 @@ func (r *Reporter) Fire(entry *log.Entry) error {
if stage != "Main" {
if v, ok := entry.Data["jobResult"]; ok {
if jobResult, ok := r.parseResult(v); ok {
// We need to ensure log upload before this upload
r.state.Result = jobResult
r.state.StoppedAt = timestamppb.New(timestamp)
for _, s := range r.state.Steps {
@@ -176,15 +179,17 @@ func (r *Reporter) Fire(entry *log.Entry) error {
}
func (r *Reporter) RunDaemon() {
if r.closed {
return
}
if r.ctx.Err() != nil {
r.stateMu.RLock()
closed := r.closed
r.stateMu.RUnlock()
if closed || r.ctx.Err() != nil {
// Acknowledge close
close(r.daemon)
return
}
_ = r.ReportLog(false)
_ = r.ReportState()
_ = r.ReportState(false)
time.AfterFunc(time.Second, r.RunDaemon)
}
@@ -226,9 +231,8 @@ func (r *Reporter) SetOutputs(outputs map[string]string) {
}
func (r *Reporter) Close(lastWords string) error {
r.closed = true
r.stateMu.Lock()
r.closed = true
if r.state.Result == runnerv1.Result_RESULT_UNSPECIFIED {
if lastWords == "" {
lastWords = "Early termination"
@@ -251,12 +255,19 @@ func (r *Reporter) Close(lastWords string) error {
})
}
r.stateMu.Unlock()
// Wait for Acknowledge
select {
case <-r.daemon:
case <-time.After(60 * time.Second):
close(r.daemon)
log.Error("No Response from RunDaemon for 60s, continue best effort")
}
return retry.Do(func() error {
if err := r.ReportLog(true); err != nil {
return err
}
return r.ReportState()
return r.ReportState(true)
}, retry.Context(r.ctx))
}
@@ -295,7 +306,9 @@ func (r *Reporter) ReportLog(noMore bool) error {
return nil
}
func (r *Reporter) ReportState() error {
// ReportState only reports the job result if reportResult is true
// RunDaemon never reports results even if result is set
func (r *Reporter) ReportState(reportResult bool) error {
r.clientM.Lock()
defer r.clientM.Unlock()
@@ -303,6 +316,11 @@ func (r *Reporter) ReportState() error {
state := proto.Clone(r.state).(*runnerv1.TaskState)
r.stateMu.RUnlock()
// Only report result from Close to reliable sent logs
if !reportResult {
state.Result = runnerv1.Result_RESULT_UNSPECIFIED
}
outputs := make(map[string]string)
r.outputs.Range(func(k, v interface{}) bool {
if val, ok := v.(string); ok {

View File

@@ -5,8 +5,11 @@ package report
import (
"context"
"fmt"
"strings"
"sync"
"testing"
"time"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
connect_go "connectrpc.com/connect"
@@ -15,6 +18,7 @@ import (
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb"
"gitea.com/gitea/act_runner/internal/pkg/client/mocks"
)
@@ -174,6 +178,7 @@ func TestReporter_Fire(t *testing.T) {
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{
Context: taskCtx,
})
reporter.RunDaemon()
defer func() {
assert.NoError(t, reporter.Close(""))
}()
@@ -195,3 +200,125 @@ func TestReporter_Fire(t *testing.T) {
assert.Equal(t, int64(3), reporter.state.Steps[0].LogLength)
})
}
// TestReporter_EphemeralRunnerDeletion reproduces the exact scenario from
// https://gitea.com/gitea/act_runner/issues/793:
//
// 1. RunDaemon calls ReportLog(false) — runner is still alive
// 2. Close() updates state to Result=FAILURE (between RunDaemon's ReportLog and ReportState)
// 3. RunDaemon's ReportState() would clone the completed state and send it,
// but the fix makes ReportState return early when closed, preventing this
// 4. Close's ReportLog(true) succeeds because the runner was not deleted
func TestReporter_EphemeralRunnerDeletion(t *testing.T) {
runnerDeleted := false
client := mocks.NewClient(t)
client.On("UpdateLog", mock.Anything, mock.Anything).Return(
func(_ context.Context, req *connect_go.Request[runnerv1.UpdateLogRequest]) (*connect_go.Response[runnerv1.UpdateLogResponse], error) {
if runnerDeleted {
return nil, fmt.Errorf("runner has been deleted")
}
return connect_go.NewResponse(&runnerv1.UpdateLogResponse{
AckIndex: req.Msg.Index + int64(len(req.Msg.Rows)),
}), nil
},
)
client.On("UpdateTask", mock.Anything, mock.Anything).Maybe().Return(
func(_ context.Context, req *connect_go.Request[runnerv1.UpdateTaskRequest]) (*connect_go.Response[runnerv1.UpdateTaskResponse], error) {
// Server deletes ephemeral runner when it receives a completed state
if req.Msg.State != nil && req.Msg.State.Result != runnerv1.Result_RESULT_UNSPECIFIED {
runnerDeleted = true
}
return connect_go.NewResponse(&runnerv1.UpdateTaskResponse{}), nil
},
)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
taskCtx, err := structpb.NewStruct(map[string]interface{}{})
require.NoError(t, err)
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx})
reporter.ResetSteps(1)
// Fire a log entry to create pending data
assert.NoError(t, reporter.Fire(&log.Entry{
Message: "build output",
Data: log.Fields{"stage": "Main", "stepNumber": 0, "raw_output": true},
}))
// Step 1: RunDaemon calls ReportLog(false) — runner is still alive
assert.NoError(t, reporter.ReportLog(false))
// Step 2: Close() updates state — sets Result=FAILURE and marks steps cancelled.
// In the real race, this happens while RunDaemon is between ReportLog and ReportState.
reporter.stateMu.Lock()
reporter.closed = true
for _, v := range reporter.state.Steps {
if v.Result == runnerv1.Result_RESULT_UNSPECIFIED {
v.Result = runnerv1.Result_RESULT_CANCELLED
}
}
reporter.state.Result = runnerv1.Result_RESULT_FAILURE
reporter.logRows = append(reporter.logRows, &runnerv1.LogRow{
Time: timestamppb.Now(),
Content: "Early termination",
})
reporter.state.StoppedAt = timestamppb.Now()
reporter.stateMu.Unlock()
// Step 3: RunDaemon's ReportState() — with the fix, this returns early
// because closed=true, preventing the server from deleting the runner.
assert.NoError(t, reporter.ReportState(false))
assert.False(t, runnerDeleted, "runner must not be deleted by RunDaemon's ReportState")
// Step 4: Close's final log upload succeeds because the runner is still alive.
// Flush pending rows first, then send the noMore signal (matching Close's retry behavior).
assert.NoError(t, reporter.ReportLog(false))
// Acknowledge Close as done in daemon
close(reporter.daemon)
err = reporter.ReportLog(true)
assert.NoError(t, err, "final log upload must not fail: runner should not be deleted before Close finishes sending logs")
err = reporter.ReportState(true)
assert.NoError(t, err, "final state update should work: runner should not be deleted before Close finishes sending logs")
}
func TestReporter_RunDaemonClose_Race(t *testing.T) {
client := mocks.NewClient(t)
client.On("UpdateLog", mock.Anything, mock.Anything).Return(
func(_ context.Context, req *connect_go.Request[runnerv1.UpdateLogRequest]) (*connect_go.Response[runnerv1.UpdateLogResponse], error) {
return connect_go.NewResponse(&runnerv1.UpdateLogResponse{
AckIndex: req.Msg.Index + int64(len(req.Msg.Rows)),
}), nil
},
)
client.On("UpdateTask", mock.Anything, mock.Anything).Return(
func(_ context.Context, req *connect_go.Request[runnerv1.UpdateTaskRequest]) (*connect_go.Response[runnerv1.UpdateTaskResponse], error) {
return connect_go.NewResponse(&runnerv1.UpdateTaskResponse{}), nil
},
)
ctx, cancel := context.WithCancel(context.Background())
taskCtx, err := structpb.NewStruct(map[string]interface{}{})
require.NoError(t, err)
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{
Context: taskCtx,
})
reporter.ResetSteps(1)
// Start the daemon loop in a separate goroutine.
// RunDaemon reads r.closed and reschedules itself via time.AfterFunc.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
reporter.RunDaemon()
}()
// Close concurrently — this races with RunDaemon on r.closed.
assert.NoError(t, reporter.Close(""))
// Cancel context so pending AfterFunc callbacks exit quickly.
cancel()
wg.Wait()
time.Sleep(2 * time.Second)
}