Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions caddy/caddy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -903,7 +903,7 @@ func TestPhpServerWorkerMatchPoolCount(t *testing.T) {
require.NoError(t, err, "failed to read metrics")

var pools []string
for _, line := range strings.Split(metrics.String(), "\n") {
for line := range strings.SplitSeq(metrics.String(), "\n") {
if !strings.HasPrefix(line, "frankenphp_total_workers{worker=") {
continue
}
Expand Down Expand Up @@ -1830,7 +1830,7 @@ func TestOpcacheReset(t *testing.T) {
wg := sync.WaitGroup{}
numRequests := 500
wg.Add(numRequests)
for i := 0; i < numRequests; i++ {
for i := range numRequests {

// introduce a delay every 10 requests
if i%10 == 0 {
Expand Down Expand Up @@ -2138,7 +2138,7 @@ func TestSymlinkWorkerBehavior(t *testing.T) {
`, "caddyfile")

// Make multiple requests - each should increment the counter
for i := 0; i < 5; i++ {
for i := range 5 {
tester.AssertGetResponse("http://localhost:"+testPort+"/index.php", http.StatusOK, fmt.Sprintf("Request: %d\n", i))
}
})
Expand Down
2 changes: 1 addition & 1 deletion caddy/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ func TestCreateUniqueWorkerNames(t *testing.T) {
filename := "../testdata/worker-with-env.php"
absFileName, _ := filepath.Abs(filename)
names := make([]string, 6)
for i := 0; i < 3; i++ {
for i := range 3 {
names[i] = app.createUniqueWorkerName(workerConfig{
FileName: filename,
Name: "custom-worker-name",
Expand Down
2 changes: 1 addition & 1 deletion caddy/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ func (f *FrankenPHPModule) ServeHTTP(w http.ResponseWriter, r *http.Request, _ c

err := f.server.ServeHTTP(w, r, opts...)

if err != nil && !errors.As(err, &frankenphp.ErrRejected{}) {
if _, rejected := errors.AsType[frankenphp.ErrRejected](err); err != nil && !rejected {
return caddyhttp.Error(http.StatusInternalServerError, err)
}

Expand Down
10 changes: 3 additions & 7 deletions cgi.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ func splitPos(path string, splitPath []string) int {

for i := 0; i <= pathLen-splitLen; i++ {
match := true
for j := 0; j < splitLen; j++ {
for j := range splitLen {
c := path[i+j]
if c >= utf8.RuneSelf {
match = false
Expand Down Expand Up @@ -363,12 +363,8 @@ func splitRemoteAddr(remoteAddr string) (ip, port string) {
return host, p
}

if idx := strings.LastIndex(remoteAddr, ":"); idx > -1 {
ip = remoteAddr[:idx]
port = remoteAddr[idx+1:]
} else {
ip = remoteAddr
}
// CutLast yields (remoteAddr, "") when there is no colon, i.e. no port.
ip, port, _ = strings.CutLast(remoteAddr, ":")

if len(ip) >= 2 && ip[0] == '[' && ip[len(ip)-1] == ']' {
ip = ip[1 : len(ip)-1]
Expand Down
6 changes: 2 additions & 4 deletions cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ func TestExecuteScriptCLI(t *testing.T) {
stdoutStderr, err := cmd.CombinedOutput()
assert.Error(t, err)

var exitError *exec.ExitError
if errors.As(err, &exitError) {
if exitError, ok := errors.AsType[*exec.ExitError](err); ok {
assert.Equal(t, 3, exitError.ExitCode())
}

Expand Down Expand Up @@ -60,8 +59,7 @@ func TestExecuteCLIPHPInfo(t *testing.T) {
if frankenphp.Version().VersionID < 80600 {
assert.Error(t, err)

var exitError *exec.ExitError
if errors.As(err, &exitError) {
if exitError, ok := errors.AsType[*exec.ExitError](err); ok {
assert.Equal(t, 1, exitError.ExitCode())
}

Expand Down
4 changes: 2 additions & 2 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,8 @@ func (fc *frankenPHPContext) reject(err error) {
return
}

re := &ErrRejected{}
if !errors.As(err, re) {
re, ok := errors.AsType[ErrRejected](err)
if !ok {
// Should never happen
panic("only instance of ErrRejected can be passed to reject")
}
Expand Down
4 changes: 2 additions & 2 deletions frankenphp.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ func Init(options ...Option) error {
}

regularThreads = make([]*phpThread, 0, opt.numThreads-workerThreadCount)
for i := 0; i < opt.numThreads-workerThreadCount; i++ {
for range opt.numThreads - workerThreadCount {
convertToRegularThread(getInactivePHPThread())
}

Expand Down Expand Up @@ -533,7 +533,7 @@ func splitRawHeader(rawHeader *C.char, length int) (string, string) {
}

// anything left is the header value
valuePtr := (*C.char)(unsafe.Pointer(uintptr(unsafe.Pointer(rawHeader)) + uintptr(j)))
valuePtr := (*C.char)(unsafe.Add(unsafe.Pointer(rawHeader), j))
headerValue := C.GoStringN(valuePtr, C.int(length-j))

return headerKey, headerValue
Expand Down
2 changes: 1 addition & 1 deletion frankenphp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ func runTest(t *testing.T, test func(func(http.ResponseWriter, *http.Request), *
assert.NoError(t, err)

err = frankenphp.ServeHTTP(w, req)
if err != nil && !errors.As(err, &frankenphp.ErrRejected{}) {
if _, rejected := errors.AsType[frankenphp.ErrRejected](err); err != nil && !rejected {
assert.Fail(t, fmt.Sprintf("Received unexpected error:\n%+v", err))
}
}
Expand Down
61 changes: 61 additions & 0 deletions goroutineleak_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package frankenphp

import (
"bytes"
"net/http/httptest"
"runtime/pprof"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// leakedGoroutines runs a leak-detection GC cycle and returns how many goroutines
// it found blocked forever on a channel or mutex that no running goroutine can
// still reach, along with their stacks.
//
// Profile.Count only reports the result of the previous detection cycle, so the
// profile has to be written first even when only the count is wanted.
func leakedGoroutines(t *testing.T) (int, string) {
t.Helper()

profile := pprof.Lookup("goroutineleak")
require.NotNil(t, profile, "the goroutineleak profile is only available since Go 1.27")

var stacks bytes.Buffer
require.NoError(t, profile.WriteTo(&stacks, 1))

return profile.Count(), stacks.String()
}

// TestNoGoroutinesAreLeakedByAFullServerLifecycle boots PHP threads, serves requests
// through a worker and a regular thread, then shuts everything down.
//
// Shutdown has to unblock every goroutine it started, so a goroutine still parked on
// an unreachable channel afterwards means a thread, a scaling ticker or a watcher
// outlived Shutdown with nothing left to wake it. The count is compared against a
// baseline rather than against zero: tests share a process, so earlier tests may have
// left leaks of their own behind.
func TestNoGoroutinesAreLeakedByAFullServerLifecycle(t *testing.T) {
before, _ := leakedGoroutines(t)

require.NoError(t, Init(
WithNumThreads(2),
WithMaxThreads(4),
WithWorkers("worker", testDataPath+"/index.php", 1, WithWorkerMaxFailures(0)),
))

for range 5 {
r := httptest.NewRequest("GET", "http://localhost/index.php", nil)
req, err := NewRequestWithContext(r, WithRequestDocumentRoot(testDataPath, false))
require.NoError(t, err)
require.NoError(t, ServeHTTP(httptest.NewRecorder(), req))
}

Shutdown()

after, stacks := leakedGoroutines(t)
t.Logf("leaked goroutines: %d before, %d after", before, after)

assert.LessOrEqual(t, after, before, "goroutines leaked across an Init/Shutdown cycle:\n%s", stacks)
}
2 changes: 1 addition & 1 deletion hotreload.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func WithHotReload(topic string, hub *mercure.Hub, patterns []string) Option {

if err := hub.Publish(globalCtx, &mercure.Update{
Topics: []string{topic},
Event: mercure.Event{Data: string(data)},
Data: string(data),
Debug: globalLogger.Enabled(globalCtx, slog.LevelDebug),
}); err != nil && globalLogger.Enabled(globalCtx, slog.LevelError) {
globalLogger.LogAttrs(globalCtx, slog.LevelError, "error publishing hot reloading Mercure update", slog.Any("error", err))
Expand Down
4 changes: 2 additions & 2 deletions internal/extgen/gofile.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,8 @@ func extractGoFunctionCallParams(goFunction string) string {
}

var names []string
parts := strings.Split(params, ",")
for _, part := range parts {
parts := strings.SplitSeq(params, ",")
for part := range parts {
part = strings.TrimSpace(part)
if len(part) == 0 {
continue
Expand Down
1 change: 0 additions & 1 deletion internal/state/state.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package state

import "C"
import (
"slices"
"sync"
Expand Down
70 changes: 41 additions & 29 deletions internal/state/state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,54 +2,66 @@ package state

import (
"testing"
"testing/synctest"
"time"

"github.com/stretchr/testify/assert"
)

func Test2GoroutinesYieldToEachOtherViaStates(t *testing.T) {
threadState := &ThreadState{currentState: Booting}
synctest.Test(t, func(t *testing.T) {
threadState := &ThreadState{currentState: Booting}

go func() {
threadState.WaitFor(Inactive)
assert.True(t, threadState.Is(Inactive))
threadState.Set(Ready)
}()
go func() {
threadState.WaitFor(Inactive)
assert.True(t, threadState.Is(Inactive))
threadState.Set(Ready)
}()

threadState.Set(Inactive)
threadState.WaitFor(Ready)
assert.True(t, threadState.Is(Ready))
threadState.Set(Inactive)
threadState.WaitFor(Ready)
assert.True(t, threadState.Is(Ready))
})
}

func TestStateShouldHaveCorrectAmountOfSubscribers(t *testing.T) {
threadState := &ThreadState{currentState: Booting}
synctest.Test(t, func(t *testing.T) {
threadState := &ThreadState{currentState: Booting}

// 3 subscribers waiting for different states
go threadState.WaitFor(Inactive)
go threadState.WaitFor(Inactive, ShuttingDown)
go threadState.WaitFor(ShuttingDown)
// 3 subscribers waiting for different states
go threadState.WaitFor(Inactive)
go threadState.WaitFor(Inactive, ShuttingDown)
go threadState.WaitFor(ShuttingDown)

assertNumberOfSubscribers(t, threadState, 3)
assertNumberOfSubscribers(t, threadState, 3)

threadState.Set(Inactive)
assertNumberOfSubscribers(t, threadState, 1)
threadState.Set(Inactive)
assertNumberOfSubscribers(t, threadState, 1)

assert.True(t, threadState.CompareAndSwap(Inactive, ShuttingDown))
assertNumberOfSubscribers(t, threadState, 0)
assert.True(t, threadState.CompareAndSwap(Inactive, ShuttingDown))
assertNumberOfSubscribers(t, threadState, 0)
})
}

func TestWaitForStateWithTimeoutGivesUpAndDropsItsSubscriber(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
threadState := &ThreadState{currentState: Booting}

// the fake clock makes the timeout fire instantly instead of after a real second
assert.False(t, threadState.WaitForStateWithTimeout(time.Second, Ready))
assert.Empty(t, threadState.subscribers)
})
}

func assertNumberOfSubscribers(t *testing.T, threadState *ThreadState, expected int) {
t.Helper()
for range 10_000 { // wait for 1 second max
time.Sleep(100 * time.Microsecond)
threadState.mu.RLock()
if len(threadState.subscribers) == expected {
threadState.mu.RUnlock()
break
}
threadState.mu.RUnlock()
}

// every subscriber goroutine is durably blocked on its channel once Wait returns,
// so the subscriber list has reached its final shape for this state
synctest.Wait()

threadState.mu.RLock()
defer threadState.mu.RUnlock()

assert.Len(t, threadState.subscribers, expected)
threadState.mu.RUnlock()
}
2 changes: 1 addition & 1 deletion internal/watcher/pattern.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ func expandCurlyBraces(s string) []string {
}

var out []string
for _, subPattern := range strings.Split(inside, ",") {
for subPattern := range strings.SplitSeq(inside, ",") {
out = append(out, expandCurlyBraces(before+subPattern+after)...)
}

Expand Down
10 changes: 4 additions & 6 deletions maxrequests_regular_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func TestModuleMaxRequests(t *testing.T) {
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))

runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) {
for i := 0; i < totalRequests; i++ {
for range totalRequests {
body, resp := testGet("http://example.com/index.php", handler, t)
assert.Equal(t, 200, resp.StatusCode)
assert.Contains(t, body, "I am by birth a Genevese")
Expand Down Expand Up @@ -50,14 +50,12 @@ func TestModuleMaxRequestsConcurrent(t *testing.T) {
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) {
var wg sync.WaitGroup

for i := 0; i < totalRequests; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for range totalRequests {
wg.Go(func() {
body, resp := testGet("http://example.com/index.php", handler, t)
assert.Equal(t, 200, resp.StatusCode)
assert.Contains(t, body, "I am by birth a Genevese")
}()
})
}
wg.Wait()
}, &testOptions{
Expand Down
Loading
Loading