diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 9c29cd13f1d..f6aa4d6374c 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -17,36 +17,84 @@ on: - master - release-** -# Only have one runner per PR, and per merged commit. -# -# - As a PR gets new commits, any old run jobs get cancelled (PR number) -# - As a commit gets merged, it doesn't cancel previous running PR's (github.sha) concurrency: - group: ${{ github.event.pull_request.number || github.sha }}-ci-tests - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: PYTHON_VERSION: "3.11" PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python jobs: + golangci-lint: + runs-on: ubuntu-latest + if: ${{ !github.event.pull_request.draft }} + steps: + - name: "Checkout PR" + uses: TykTechnologies/github-actions/.github/actions/checkout-pr@main + with: + token: ${{ secrets.ORG_GH_TOKEN }} + + - name: "Get base ref" + run: | + git fetch origin ${{ github.base_ref }} + git rev-parse origin/${{ github.base_ref }} + + - name: Setup Golang + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Cache + uses: actions/cache@v4 + with: + path: | + ~/.cache/golangci-lint + key: 'golangci-lint-${{ runner.os }}-${{ hashFiles(''**/go.sum'') }}' + restore-keys: | + golangci-lint-${{ runner.os }}- + + - name: Setup CI Tooling + uses: shrink/actions-docker-extract@v3 + with: + image: tykio/ci-tools:latest + path: /usr/local/bin/golangci-lint + destination: /usr/local/bin + + - run: golangci-lint version && golangci-lint cache status + + - name: golangci-lint + if: ${{ github.event_name == 'pull_request' }} + run: | + golangci-lint run --out-format colored-line-number,checkstyle:golangci-lint-report.json --issues-exit-code=0 --new-from-rev=origin/${{ github.base_ref }} -v ./... + + - name: golangci-lint-on-push + if: ${{ github.event_name == 'push' }} + run: | + golangci-lint run --out-format checkstyle:golangci-lint-report.json --issues-exit-code=0 -v ./... + + - uses: actions/upload-artifact@v4 + if: ${{ always() }} + with: + name: golangcilint + retention-days: 1 + path: | + golangci-lint-report.json + test: name: Go ${{ matrix.go-version }} Redis ${{ matrix.redis-version }} + if: ${{ !github.event.pull_request.draft }} + needs: golangci-lint + # Runs on is pinned to a version that provides python 3.10. + # See: https://github.com/actions/runner-images?tab=readme-ov-file#available-images + # Avoid using ubuntu-latest as it would upgrade python unattended. runs-on: ubuntu-22.04 strategy: - fail-fast: true - # This workflow isn't designed to be run as a pipeline, several issues: - # - # - contains golangci-lint jobs, sonarcloud (would duplicate) - # - cache config not suitable for multiple pipelines - # - python tests should be separate job, or no job - # - # Keep it to a single job run from the matrix as configured - # until we get a chance to redesign the pipeline properly. + fail-fast: false matrix: redis-version: [7] - python-version: ["3.11"] - go-version: [1.22.x] + go-version: [1.23.x] env: REDIS_IMAGE: redis:${{ matrix.redis-version }} @@ -57,15 +105,18 @@ jobs: with: ref: ${{ github.event.pull_request.head.ref }} - - name: Setup Golang - uses: actions/setup-go@v2 - with: - go-version: ${{ matrix.go-version }} - + # Regardless that the base image provides a python release, we need + # setup-python so it properly configures the python3-venv. - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: ${{ env.PYTHON_VERSION }} + + - name: Print runtime python version + run: python3 -c 'import sys; print("%d.%d" % (sys.version_info[0], sys.version_info[1]))' + + - name: Print runtime pip version + run: pip -V && pip3 -V - name: Setup CI Tooling uses: shrink/actions-docker-extract@v3 @@ -74,15 +125,11 @@ jobs: path: /usr/local/bin/. destination: /usr/local/bin - - name: Cache - uses: actions/cache@v4 + - name: Setup Golang + uses: actions/setup-go@v5 with: - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-${{ hashFiles('**/go.sum') }} + go-version-file: go.mod + cache-dependency-path: go.sum - name: Install Dependencies and basic hygiene test id: hygiene @@ -113,15 +160,12 @@ jobs: exit 1 fi - - name: Fetch base branch - if: ${{ github.event_name == 'pull_request' }} - run: git fetch origin ${{ github.base_ref }} - - - name: Print CPU info - run: grep '^model name' /proc/cpuinfo + - name: Bring up test services + run: task services:up - - name: Print Go env - run: go env + - name: Preflight Python tests + if: runner.debug == '1' + run: TYK_LOGLEVEL=debug go test -p 1 -parallel 1 -race -v ./dlpython ./coprocess/... - name: Run Gateway Tests id: ci-tests @@ -129,17 +173,51 @@ jobs: task test:e2e-combined args="-race -timeout=15m" task test:coverage - # golangci-lint actions *require* issues-exit-code=0 to pass data along to sonarcloud - # rather than erroring out on github issues directly with out-format github. - - name: golangci-lint - if: ${{ github.event_name == 'pull_request' }} - run: | - golangci-lint run --out-format checkstyle --issues-exit-code=0 --new-from-rev=origin/${{ github.base_ref }} ./... > golanglint.xml + - uses: actions/upload-artifact@v4 + if: ${{ always() }} + with: + name: coverage + retention-days: 1 + path: coverage/gateway-all.cov - - name: golangci-lint-on-push - if: ${{ github.event_name == 'push' }} - run: | - golangci-lint run --out-format checkstyle --issues-exit-code=0 ./... > golanglint.xml + - uses: actions/upload-artifact@v4 + if: ${{ always() }} + with: + name: testjson + retention-days: 1 + path: coverage/gateway-all.json + + sonar-cloud-analysis: + runs-on: ubuntu-latest + if: ${{ !github.event.pull_request.draft }} + needs: [test, golangci-lint] + steps: + - name: "Checkout repository" + uses: TykTechnologies/github-actions/.github/actions/checkout-pr@main + with: + token: ${{ secrets.ORG_GH_TOKEN }} + + - name: Download coverage artifacts + uses: actions/download-artifact@v4 + with: + name: coverage + + - name: Download golangcilint artifacts + uses: actions/download-artifact@v4 + with: + name: golangcilint + + - name: Check reports existence + id: check_files + uses: andstor/file-existence-action@v3 + with: + files: 'gateway-all.cov, golangci-lint-report.json' + fail: true + + - name: Install Dependencies + env: + TOKEN: '${{ secrets.ORG_GH_TOKEN }}' + run: git config --global url."https://${TOKEN}@github.com".insteadOf "https://github.com" - name: SonarCloud Scan if: always() @@ -149,12 +227,12 @@ jobs: -Dsonar.organization=tyktechnologies -Dsonar.projectKey=TykTechnologies_tyk -Dsonar.sources=. - -Dsonar.exclusions=**/testdata/*,test/**,coprocess/**/*,ci/**,smoke-tests/**,apidef/oas/schema/schema.gen.go + -Dsonar.exclusions=**/testdata/*,test/**,coprocess/**/*,ci/**,smoke-tests/**,apidef/oas/schema/schema.gen.go,templates/** -Dsonar.coverage.exclusions=**/*_test.go,**/mock/* -Dsonar.test.inclusions=**/*_test.go -Dsonar.tests=. - -Dsonar.go.coverage.reportPaths=coverage/gateway-all.cov - -Dsonar.go.golangci-lint.reportPaths=golanglint.xml + -Dsonar.go.coverage.reportPaths=gateway-all.cov + -Dsonar.go.golangci-lint.reportPaths=golangci-lint-report.json env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/plugin-compiler-build.yml b/.github/workflows/plugin-compiler-build.yml index 835af14fd46..4003e0b56e0 100644 --- a/.github/workflows/plugin-compiler-build.yml +++ b/.github/workflows/plugin-compiler-build.yml @@ -8,19 +8,29 @@ on: - master - release-** tags: - - 'v*' + - "v*" env: - GOLANG_CROSS: 1.22-bullseye + GOLANG_CROSS: 1.23-bullseye + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: docker-build: runs-on: ubuntu-latest + if: ${{ !github.event.pull_request.draft }} permissions: id-token: write steps: + - name: "Reclaim some runner space" + run: sudo rm -rf /usr/local/bin/* /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL + - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 + with: + fetch-depth: 1 - name: Configure AWS Credentials id: configure-aws @@ -58,7 +68,7 @@ jobs: password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push to dockerhub/ECR - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v4 with: context: . file: ci/images/plugin-compiler/Dockerfile @@ -67,6 +77,6 @@ jobs: labels: ${{ steps.set-metadata.outputs.labels }} tags: ${{ steps.set-metadata.outputs.tags }} build-args: | - BASE-IMAGE=tykio/golang-cross:${{ env.GOLANG_CROSS }} + BASE_IMAGE=tykio/golang-cross:${{ env.GOLANG_CROSS }} GITHUB_SHA=${{ github.sha }} GITHUB_TAG=${{ github.ref_name }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 46d789da2e0..6e537f3ecb4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,9 +41,9 @@ jobs: fail-fast: false matrix: golang_cross: - - 1.22-bullseye + - 1.23-bullseye include: - - golang_cross: 1.22-bullseye + - golang_cross: 1.23-bullseye goreleaser: 'ci/goreleaser/goreleaser.yml' cgo: 1 rpmvers: 'el/7 el/8 el/9 amazon/2 amazon/2023' @@ -127,12 +127,12 @@ jobs: mask-aws-account-id: false - uses: aws-actions/amazon-ecr-login@v2 id: ecr - if: ${{ matrix.golang_cross == '1.22-bullseye' }} + if: ${{ matrix.golang_cross == '1.23-bullseye' }} with: mask-password: 'true' - name: Docker metadata for CI id: ci_metadata - if: ${{ matrix.golang_cross == '1.22-bullseye' }} + if: ${{ matrix.golang_cross == '1.23-bullseye' }} uses: docker/metadata-action@v5 with: images: ${{ steps.ecr.outputs.registry }}/tyk @@ -146,7 +146,7 @@ jobs: type=semver,pattern={{major}}.{{minor}},prefix=v type=semver,pattern={{version}},prefix=v - name: push image to CI - if: ${{ matrix.golang_cross == '1.22-bullseye' }} + if: ${{ matrix.golang_cross == '1.23-bullseye' }} uses: docker/build-push-action@v6 with: context: "dist" @@ -174,7 +174,7 @@ jobs: type=semver,pattern={{version}} labels: "org.opencontainers.image.title=tyk-gateway (distroless) \norg.opencontainers.image.description=Tyk Open Source API Gateway written in Go, supporting REST, GraphQL, TCP and gRPC protocols\norg.opencontainers.image.vendor=tyk.io\norg.opencontainers.image.version=${{ github.ref_name }}\n" - name: push image to prod - if: ${{ matrix.golang_cross == '1.22-bullseye' }} + if: ${{ matrix.golang_cross == '1.23-bullseye' }} uses: docker/build-push-action@v6 with: context: "dist" @@ -189,7 +189,7 @@ jobs: labels: ${{ steps.tag_metadata.outputs.labels }} - name: save deb uses: actions/upload-artifact@v4 - if: ${{ matrix.golang_cross == '1.22-bullseye' }} + if: ${{ matrix.golang_cross == '1.23-bullseye' }} with: name: deb retention-days: 1 @@ -199,7 +199,7 @@ jobs: !dist/*fips*.deb - name: save rpm uses: actions/upload-artifact@v4 - if: ${{ matrix.golang_cross == '1.22-bullseye' }} + if: ${{ matrix.golang_cross == '1.23-bullseye' }} with: name: rpm retention-days: 1 diff --git a/.taskfiles/deps/Taskfile.yml b/.taskfiles/deps.yml similarity index 100% rename from .taskfiles/deps/Taskfile.yml rename to .taskfiles/deps.yml diff --git a/.taskfiles/hooks.yml b/.taskfiles/hooks.yml new file mode 100644 index 00000000000..56bf85fad0b --- /dev/null +++ b/.taskfiles/hooks.yml @@ -0,0 +1,23 @@ +version: "3" + +includes: + lint_dep: lint.yml + +tasks: + pre-commit: + desc: "Run pre-commit hooks" + cmds: + - task: :lint:tidy + - task: lint_dep:compile-tests + + pre-push: + desc: "Run pre-push hooks" + cmds: + - task: :lint:tidy + - task: lint_dep:vet + - task: lint_dep:golangci-lint + vars: + args: "{{.golangci}} --new-from-rev=origin/master" + - task: :lint:x-tyk-gateway + - task: lint_dep:compile-tests + - task: lint_dep:simple-build diff --git a/.taskfiles/test.yml b/.taskfiles/test.yml index d6933c4b372..368babe2f6c 100644 --- a/.taskfiles/test.yml +++ b/.taskfiles/test.yml @@ -2,7 +2,7 @@ version: "3" includes: - deps: ./deps/Taskfile.yml + deps: ./deps.yml services: taskfile: ../docker/services/Taskfile.yml dir: ../docker/services diff --git a/Dockerfile b/Dockerfile index eacf83f7513..0b9d631f9f1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,68 +1,28 @@ -FROM debian:bullseye AS assets - -# This Dockerfile facilitates bleeding edge development docker image builds -# directly from source. To build a development image, run `make docker`. -# If you need to tweak the environment for testing, you can override the -# `GO_VERSION` and `PYTHON_VERSION` as docker build arguments. - -ARG GO_VERSION=1.22.6 -ARG PYTHON_VERSION=3.11.6 - -WORKDIR /assets - -RUN apt update && apt install wget -y && \ - wget -q https://dl.google.com/go/go${GO_VERSION}.linux-amd64.tar.gz && \ - wget -q https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tar.xz - -FROM debian:bullseye - -ARG GO_VERSION=1.22.6 -ARG PYTHON_VERSION=3.11.6 - -COPY --from=assets /assets/ /tmp/ -WORKDIR /tmp - -# Install Go - -ENV PATH=$PATH:/usr/local/go/bin - -RUN tar -C /usr/local -xzf go${GO_VERSION}.linux-amd64.tar.gz && \ - go version +ARG GO_VERSION=1.23 +FROM golang:${GO_VERSION}-bullseye # Build essentials RUN apt update && apt install build-essential zlib1g-dev libncurses5-dev libgdbm-dev libnss3-dev libssl-dev libsqlite3-dev libreadline-dev libffi-dev curl wget libbz2-dev -y -# Install $PYTHON_VERSION - ## This just installs whatever is is bullseye, makes docker build (fast/small)-(er) RUN apt install python3 -y -## This runs python code slower, but the process finishes quicker -# RUN tar -xf Python-${PYTHON_VERSION}.tar.xz && ls -la && \ -# cd Python-${PYTHON_VERSION}/ && \ -# ./configure --enable-shared && make build_all && \ -# make altinstall && \ -# ldconfig $PWD - -## This runs python code faster, but is expensive to build and runs regression tests -# RUN tar -xf Python-${PYTHON_VERSION}.tar.xz && ls -la && \ -# cd Python-${PYTHON_VERSION}/ && \ -# ./configure --enable-shared --enable-optimizations && make -j 2 && \ -# make altinstall && \ -# ldconfig $PWD - -# Clean up build assets -RUN find /tmp -type f -delete - # Build gateway RUN mkdir /opt/tyk-gateway WORKDIR /opt/tyk-gateway + ADD go.mod go.sum /opt/tyk-gateway/ -RUN go mod download + +RUN --mount=type=cache,mode=0755,target=/go/pkg/mod \ + --mount=type=cache,mode=0755,target=/root/.cache/go-build \ + go mod download + ADD . /opt/tyk-gateway -RUN make build +RUN --mount=type=cache,mode=0755,target=/go/pkg/mod \ + --mount=type=cache,mode=0755,target=/root/.cache/go-build \ + make build COPY tyk.conf.example tyk.conf diff --git a/Taskfile.yml b/Taskfile.yml index c390b454509..e05c86b9b12 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -4,7 +4,8 @@ version: "3" includes: test: .taskfiles/test.yml - deps: .taskfiles/deps/Taskfile.yml + deps: .taskfiles/deps.yml + hooks: .taskfiles/hooks.yml opentelemetry: taskfile: ci/tests/tracing/Taskfile.yml dir: ci/tests/tracing/ @@ -25,7 +26,7 @@ tasks: docker: desc: "build Tyk gateway internal/tyk-gateway" cmds: - - docker build --platform "linux/amd64" --rm -t internal/tyk-gateway . + - docker build --build-arg GO_VERSION="$(go mod edit -json | jq .Go -r)" --platform "linux/amd64" --rm -t internal/tyk-gateway . sources: - go.mod - go.sum diff --git a/ci/images/plugin-compiler/Dockerfile b/ci/images/plugin-compiler/Dockerfile index 1127fa4e0ed..8bcdaf68c38 100644 --- a/ci/images/plugin-compiler/Dockerfile +++ b/ci/images/plugin-compiler/Dockerfile @@ -1,4 +1,4 @@ -ARG BASE_IMAGE=tykio/golang-cross:1.22-bullseye +ARG BASE_IMAGE=tykio/golang-cross:1.23-bullseye FROM ${BASE_IMAGE} LABEL description="Image for plugin development" diff --git a/gateway/gateway_test.go b/gateway/gateway_test.go index a00246d137d..c51868b6ad7 100644 --- a/gateway/gateway_test.go +++ b/gateway/gateway_test.go @@ -11,7 +11,6 @@ import ( "net/http/httptest" "net/url" "os" - "runtime" "strconv" "strings" "sync/atomic" @@ -642,61 +641,6 @@ func TestListenPathTykPrefix(t *testing.T) { }) } -func TestReloadGoroutineLeakWithTest(t *testing.T) { - test.Flaky(t) - - before := runtime.NumGoroutine() - - ts := StartTest(nil) - ts.Close() - - time.Sleep(time.Second) - - after := runtime.NumGoroutine() - - if before < after { - t.Errorf("Goroutine leak, was: %d, after reload: %d", before, after) - } -} - -func TestReloadGoroutineLeakWithCircuitBreaker(t *testing.T) { - ts := StartTest(nil) - t.Cleanup(ts.Close) - - globalConf := ts.Gw.GetConfig() - globalConf.EnableJSVM = false - ts.Gw.SetConfig(globalConf) - - specs := ts.Gw.BuildAndLoadAPI(func(spec *APISpec) { - spec.Proxy.ListenPath = "/" - UpdateAPIVersion(spec, "v1", func(version *apidef.VersionInfo) { - version.ExtendedPaths = apidef.ExtendedPathsSet{ - CircuitBreaker: []apidef.CircuitBreakerMeta{ - { - Path: "/", - Method: http.MethodGet, - ThresholdPercent: 0.5, - Samples: 5, - ReturnToServiceAfter: 10, - }, - }, - } - }) - }) - - before := runtime.NumGoroutine() - - ts.Gw.LoadAPI(specs...) // just doing globalGateway.DoReload() doesn't load anything as BuildAndLoadAPI cleans up folder with API specs - - time.Sleep(100 * time.Millisecond) - - after := runtime.NumGoroutine() - - if before < after { - t.Errorf("Goroutine leak, was: %d, after reload: %d", before, after) - } -} - func listenProxyProto(ls net.Listener) error { pl := &proxyproto.Listener{Listener: ls} for { diff --git a/go.mod b/go.mod index da5ad8ffb30..728bb45f699 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/TykTechnologies/tyk -go 1.22.6 +go 1.23.4 require ( github.com/Jeffail/tunny v0.1.4 diff --git a/internal/debug2/goroutine.go b/internal/debug2/goroutine.go new file mode 100644 index 00000000000..115975597c1 --- /dev/null +++ b/internal/debug2/goroutine.go @@ -0,0 +1,124 @@ +package debug2 + +import ( + "bytes" + "fmt" + "regexp" + "runtime/pprof" + "strings" +) + +// Record captures goroutine states +type Record struct { + buffer *bytes.Buffer + ignores []string +} + +// NewRecord creates a new Record and populates it with the current goroutine dump. +func NewRecord() *Record { + result := &Record{ + buffer: bytes.NewBuffer([]byte{}), + } + + pprof.Lookup("goroutine").WriteTo(result.buffer, 1) + + result.SetIgnores([]string{ + "runtime/pprof.writeRuntimeProfile", + }) + return result +} + +func (r *Record) SetIgnores(ignores []string) { + r.ignores = ignores +} + +var headerMatchRe = regexp.MustCompile(`^[0-9]+ @ 0x.*`) + +// parseGoroutines parses goroutines from the buffer into a map where each key is a +// goroutine header and the value is its stack trace as a slice of strings. +func (r *Record) parseGoroutines() map[string][]string { + goroutines := make(map[string][]string) + var currentHeader string + var currentStack []string + toDelete := []string{} + lines := strings.Split(r.buffer.String(), "\n") + + for _, line := range lines { + var skip bool + for _, ign := range r.ignores { + if strings.Contains(line, ign) { + skip = true + break + } + } + + if skip { + toDelete = append(toDelete, currentHeader) + } + + if headerMatchRe.MatchString(line) { + // Save the previous goroutine and reset + if currentHeader != "" { + goroutines[currentHeader] = currentStack + } + currentHeader = line + currentStack = []string{line} + } else if currentHeader != "" { + // Add stack trace lines to the current goroutine + currentStack = append(currentStack, line) + } + } + + // Save the last goroutine + if currentHeader != "" { + goroutines[currentHeader] = currentStack + } + + for _, key := range toDelete { + delete(goroutines, key) + } + + return goroutines +} + +// Since compares the current Record with another Record and returns a new Record +// containing only the goroutines found in the current Record but not in the last. +func (r *Record) Since(last *Record) *Record { + currentGoroutines := r.parseGoroutines() + lastGoroutines := last.parseGoroutines() + + diffBuffer := bytes.NewBuffer([]byte{}) + for header, stack := range currentGoroutines { + if _, exists := lastGoroutines[header]; !exists { + diffBuffer.WriteString(header + "\n") + for _, line := range stack { + diffBuffer.WriteString(line + "\n") + } + } + } + + return &Record{ + buffer: diffBuffer, + } +} + +// Count returns the number of unique goroutines in the Record. +func (r *Record) Count() int { + return len(r.parseGoroutines()) +} + +// String implements the fmt.Stringer interface, providing a formatted view +// of the goroutines in the Record. +func (r *Record) String() string { + goroutines := r.parseGoroutines() + var builder strings.Builder + builder.WriteString(fmt.Sprintf("Number of goroutines: %d\n", len(goroutines))) + for header, stack := range goroutines { + builder.WriteString("--- Goroutine ---\n") + builder.WriteString(header + "\n") + for _, line := range stack { + builder.WriteString(line + "\n") + } + } + return builder.String() +} diff --git a/internal/debug2/goroutine_test.go b/internal/debug2/goroutine_test.go new file mode 100644 index 00000000000..0d55d582247 --- /dev/null +++ b/internal/debug2/goroutine_test.go @@ -0,0 +1,103 @@ +package debug2_test + +import ( + "context" + "fmt" + "runtime" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/TykTechnologies/tyk/internal/debug2" +) + +func TestNewRecordWithGoroutines(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + time.Sleep(100 * time.Millisecond) + + // Capture the initial state of goroutines + initialRecord := debug2.NewRecord() + + // Create and start a new goroutine + go func() { + time.Sleep(100 * time.Millisecond) + }() + go func() { + time.Sleep(100 * time.Millisecond) + }() + + // Capture the state after starting the goroutine + intermediateRecord := debug2.NewRecord() + // t.Log("The intermediate goroutines:\n", intermediateRecord.String()) + + newGoroutines := intermediateRecord.Since(initialRecord) + assert.Equal(t, 2, newGoroutines.Count(), "Expected new goroutines, but found none") + + for { + // Wait for the goroutine to finish + time.Sleep(100 * time.Millisecond) + runtime.GC() + time.Sleep(10 * time.Millisecond) + + // Capture the state after the goroutine has finished + finalRecord := debug2.NewRecord() + remainingGoroutines := finalRecord.Since(initialRecord) + + // Expecting goroutines clear + if remainingGoroutines.Count() == 0 { + break + } + + if ctx.Err() != nil { + break + } + + fmt.Print(remainingGoroutines.String()) + } + + assert.NoError(t, ctx.Err(), "cancelled goroutine leak check after timeout") +} + +func BenchmarkNewRecordWithGoroutines(b *testing.B) { + // Capture the initial state of goroutines + initialRecord := debug2.NewRecord() + + // Create and start a new goroutine + + var wg sync.WaitGroup + wg.Add(b.N) + + var i int + for i = 0; i < b.N; i++ { + go func() { + defer wg.Done() + + time.Sleep(100 * time.Millisecond) + }() + } + + // Capture the state after starting the goroutine + intermediateRecord := debug2.NewRecord() + b.Logf("Started %d goroutines with sleep", b.N) + b.Log("Intermediate Record count: ", intermediateRecord.Count()) + + wg.Wait() + + runtime.GC() + + // Capture the state after the goroutine has finished + finalRecord := debug2.NewRecord() + b.Log("Finished with finalRecord count: ", finalRecord.Count()) + + // Check that the intermediate record contains the new goroutine + newGoroutines := intermediateRecord.Since(initialRecord) + assert.Greater(b, newGoroutines.Count(), 0, "Expected new goroutines, but found none") + + // Check that the final record no longer contains the new goroutine + remainingGoroutines := finalRecord.Since(initialRecord) + assert.Equal(b, 0, remainingGoroutines.Count(), "Expected no new goroutines, but found: "+remainingGoroutines.String()) +} diff --git a/tests/system/README.md b/tests/system/README.md new file mode 100644 index 00000000000..853187c45b9 --- /dev/null +++ b/tests/system/README.md @@ -0,0 +1,5 @@ +# System tests + +These tests are system level and depend on the current code structure +and go toolchain behaviour. They are flaky as code and the go toolchain +changes and drifts from what's asserted here. diff --git a/tests/system/goroutine_test.go b/tests/system/goroutine_test.go new file mode 100644 index 00000000000..99527cb7a69 --- /dev/null +++ b/tests/system/goroutine_test.go @@ -0,0 +1,97 @@ +package system_test + +import ( + "net/http" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/TykTechnologies/tyk/apidef" + "github.com/TykTechnologies/tyk/gateway" + "github.com/TykTechnologies/tyk/internal/debug2" + "github.com/TykTechnologies/tyk/test" +) + +func TestReloadGoroutineLeakWithTest(t *testing.T) { + test.Flaky(t) + + newRecord := func() *debug2.Record { + result := debug2.NewRecord() + result.SetIgnores([]string{ + "runtime/pprof.writeRuntimeProfile", + "/root/go/pkg/mod/github.com/!tyk!technologies/leakybucket@v0.0.0-20170301023702-71692c943e3c/memorycache/cache.go:69", + "/root/go/pkg/mod/github.com/pmylund/go-cache@v2.1.0+incompatible/cache.go:1079", + "/root/tyk/tyk/gateway/distributed_rate_limiter.go:31", + "/root/tyk/tyk/gateway/redis_signals.go:68", + }) + + return result + } + + before := newRecord() + require.Less(t, before.Count(), 100, "before count over a 100, leak: %s", before) + + ts := gateway.StartTest(nil) + ts.Close() + + time.Sleep(100 * time.Millisecond) + runtime.GC() + + final := newRecord().Since(before) + assert.Equal(t, 0, final.Count(), "final count not zero: %s", final) +} + +func TestReloadGoroutineLeakWithCircuitBreaker(t *testing.T) { + test.Flaky(t) + + ts := gateway.StartTest(nil) + t.Cleanup(ts.Close) + + newRecord := func() *debug2.Record { + result := debug2.NewRecord() + result.SetIgnores([]string{ + "runtime/pprof.writeRuntimeProfile", + "/root/tyk/tyk/gateway/reverse_proxy.go:223", + "/root/tyk/tyk/gateway/api_definition.go:1025", + "/root/tyk/tyk/gateway/distributed_rate_limiter.go:31", + "/root/go/pkg/mod/github.com/pmylund/go-cache@v2.1.0+incompatible/cache.go:1079", + "/root/go/pkg/mod/github.com/!tyk!technologies/circuitbreaker@v2.2.2+incompatible/circuitbreaker.go:202", + }) + + return result + } + + globalConf := ts.Gw.GetConfig() + globalConf.EnableJSVM = false + ts.Gw.SetConfig(globalConf) + + stage1 := newRecord() + + specs := ts.Gw.BuildAndLoadAPI(func(spec *gateway.APISpec) { + spec.Proxy.ListenPath = "/" + gateway.UpdateAPIVersion(spec, "v1", func(version *apidef.VersionInfo) { + version.ExtendedPaths = apidef.ExtendedPathsSet{ + CircuitBreaker: []apidef.CircuitBreakerMeta{ + { + Path: "/", + Method: http.MethodGet, + ThresholdPercent: 0.5, + Samples: 5, + ReturnToServiceAfter: 10, + }, + }, + } + }) + }) + + ts.Gw.LoadAPI(specs...) // just doing globalGateway.DoReload() doesn't load anything as BuildAndLoadAPI cleans up folder with API specs + + time.Sleep(100 * time.Millisecond) + runtime.GC() + + final := newRecord().Since(stage1) + assert.Equal(t, 0, final.Count(), "final count not zero: %s", final) +}