Skip to content
86 changes: 86 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Used as inspiration: https://github.com/caddyserver/caddy/blob/master/.github/workflows/ci.yml

name: Tests

on:
push:
branches:
- master
pull_request:
branches:
- master

env:
# https://github.com/actions/setup-go/issues/491
GOTOOLCHAIN: local

permissions:
contents: read

jobs:
test:
strategy:
# Default is true, cancels jobs for other platforms in the matrix if one fails
fail-fast: false
matrix:
os:
- linux
go:
- '1.27'

include:
# Set the minimum Go patch version for the given Go minor
# Usable via ${{ matrix.GO_SEMVER }}
- go: '1.27'
GO_SEMVER: '~1.27.0'

# Set some variables per OS, usable via ${{ matrix.VAR }}
# OS_LABEL: the VM label from GitHub Actions
- os: linux
OS_LABEL: ubuntu-latest

runs-on: ${{ matrix.OS_LABEL }}
permissions:
contents: read
pull-requests: read

steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit

- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Install Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version: ${{ matrix.GO_SEMVER }}
check-latest: true

- name: Print Go version and environment
shell: bash
run: |
printf "Using go at: $(which go)\n"
printf "Go version: $(go version)\n"
printf "\n\nGo environment:\n\n"
go env
printf "\n\nSystem environment:\n\n"
env
printf "Git version: $(git version)\n\n"

- name: Get dependencies
run: |
go get -v -t -d ./...

- name: Run vet
run: |
go vet ./...

# The test suite loads packages with the Go toolchain and sets
# CGO_ENABLED=0 itself where needed; the race detector requires
# cgo, so CGO_ENABLED is not forced off here.
- name: Run tests
run: |
go test -short -race ./...
46 changes: 46 additions & 0 deletions baseline_behavior_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package moduledoc

import (
"os"
"testing"

"golang.org/x/tools/go/packages"
)

// TestOriginalBehaviorBaseline establishes baseline behavior for the original implementation
func TestOriginalBehaviorBaseline(t *testing.T) {
t.Run("ValidStaticModule", func(t *testing.T) {
// Test only the gizmo.go file which should work
cfg := &packages.Config{
Dir: ".",
Mode: packagesLoadMode,
Env: append(os.Environ(), "CGO_ENABLED=0"),
}

pkgs, err := packages.Load(cfg, "./testdata")
if err != nil {
t.Fatalf("loading testdata package: %v", err)
}
if len(pkgs) == 0 {
t.Fatal("no packages loaded")
}

driver := New(nil)
pkg := pkgs[0]

moduleIdents, err := driver.findCaddyModuleIdents(pkg)
if err != nil {
t.Fatalf("finding module idents: %v", err)
}

found := false
for _, moduleID := range moduleIdents {
if moduleID == "app.namespace.gizmo" {
found = true
}
}
if !found {
t.Errorf("expected module 'app.namespace.gizmo', got: %v", moduleIdents)
}
})
}
28 changes: 28 additions & 0 deletions concurrency_baseline_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package moduledoc

import (
"testing"
)

// TestMemoryLeakDemo demonstrates the unbounded cache growth
func TestMemoryLeakDemo(t *testing.T) {
t.Run("UnboundedCacheGrowth", func(t *testing.T) {
driver := New(nil)

// Simulate what happens with many package versions
for i := 0; i < 1000; i++ {
for version := 0; version < 5; version++ {
key := "test.package." + string(rune('A'+i)) + "@v1." + string(rune('0'+version)) + ".0"
driver.setDiscoveredType(key, &Value{
Type: String,
TypeName: key,
})
}
}

// no size limit, TTL, or eviction: every entry is retained
if len(driver.discoveredTypes) != 5000 {
t.Errorf("expected 5000 retained entries, got %d", len(driver.discoveredTypes))
}
})
}
163 changes: 163 additions & 0 deletions concurrency_edge_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package moduledoc

import (
"os"
"os/exec"
"sync"
"testing"

"golang.org/x/tools/go/packages"
)

// localWorkspace returns a workspace anchored at the repository root so
// the local testdata package can be loaded without running 'go get'.
func localWorkspace(t *testing.T, d *Driver) workspace {
t.Helper()
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getting working directory: %v", err)
}
return workspace{
mu: new(sync.RWMutex),
dir: cwd,
driver: d,
goGets: map[string]struct{}{"github.com/caddyserver/moduledoc": {}},
packagePatterns: make(map[string][]string),
parsedPackages: make(map[string]*packages.Package),
}
}

const testdataPackagePath = "github.com/caddyserver/moduledoc/testdata"

// runIsolated re-runs the named test in a child process with childEnv set,
// so fatal runtime errors or race reports cannot take down the whole suite.
func runIsolated(t *testing.T, testName, childEnv, failureMsg string) {
t.Helper()
cmd := exec.Command(os.Args[0], "-test.run", "^"+testName+"$", "-test.v")
cmd.Env = append(os.Environ(), childEnv+"=1")
out, err := cmd.CombinedOutput()
if err != nil {
if len(out) > 4000 {
out = out[:4000]
}
t.Fatalf("%s: %v\n%s", failureMsg, err, out)
}
}

func TestConcurrentDiscoveredTypesAccess(t *testing.T) {
if os.Getenv("MODULEDOC_TEST_DISCOVERED_TYPES_CHILD") == "1" {
db := newMemStorage()
d := New(db)
ws := localWorkspace(t, d)

pkgs, err := ws.getPackages(testdataPackagePath, "")
if err != nil {
t.Fatalf("loading testdata package: %v", err)
}
obj := pkgs[0].Types.Scope().Lookup("Gizmo")
if obj == nil {
t.Fatal("Gizmo type not found in testdata package")
}

// pre-store the type so every build takes the db-hit path,
// which writes to the driver's type cache on each call
db.StoreType(testdataPackagePath, "Gizmo", "", &Value{
Type: Struct,
TypeName: testdataPackagePath + ".Gizmo",
})

start := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
rb := ws.representationBuilder()
if _, err := rb.buildRepresentation(obj.Type()); err != nil {
t.Errorf("building representation: %v", err)
}
}()
}
close(start)
wg.Wait()
return
}

if testing.Short() {
t.Skip("requires the Go toolchain")
}
runIsolated(t, "TestConcurrentDiscoveredTypesAccess", "MODULEDOC_TEST_DISCOVERED_TYPES_CHILD",
"concurrent representation building on a shared Driver must be safe")
}

func TestConcurrentModuleTypeLoading(t *testing.T) {
if os.Getenv("MODULEDOC_TEST_MODULE_LOADING_CHILD") == "1" {
db := newMemStorage()
db.types[storageKey("example.com/pkg", "Handler", "")] = &Value{
Type: Struct,
TypeName: "example.com/pkg.Handler",
Doc: "handler doc",
StructFields: []*StructField{
{Key: "root", Value: &Value{Type: String}, Doc: "root doc"},
},
}
db.modules["http.handlers.x"] = []*Value{
{SameAs: "example.com/pkg.Handler", Doc: "usage doc"},
}
d := New(db)

start := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
if _, err := d.LoadTypesByModuleID("http.handlers.x"); err != nil {
t.Errorf("loading module types: %v", err)
}
}()
}
close(start)
wg.Wait()
return
}

runIsolated(t, "TestConcurrentModuleTypeLoading", "MODULEDOC_TEST_MODULE_LOADING_CHILD",
"concurrent module type loading on a shared Driver must be safe")
}

func TestConcurrentGetPackages(t *testing.T) {
if os.Getenv("MODULEDOC_TEST_GET_PACKAGES_CHILD") == "1" {
d := New(newMemStorage())
ws := localWorkspace(t, d)

start := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
pkgs, err := ws.getPackages(testdataPackagePath, "")
if err != nil {
t.Errorf("loading packages: %v", err)
return
}
if len(pkgs) != 1 {
t.Errorf("expected 1 package, got %d", len(pkgs))
}
}()
}
close(start)
wg.Wait()
return
}

if testing.Short() {
t.Skip("requires the Go toolchain")
}
runIsolated(t, "TestConcurrentGetPackages", "MODULEDOC_TEST_GET_PACKAGES_CHILD",
"concurrent package loading on a shared workspace must be safe")
}
Loading