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
145 changes: 145 additions & 0 deletions bgworker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package frankenphp_test

import (
"net/http"
"os"
"path/filepath"
"testing"
"time"

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

// requireFileEventually asserts that `path` appears on disk before the
// deadline. Wraps require.Eventually so call sites stay short.
func requireFileEventually(t testing.TB, path string, msgAndArgs ...any) {
t.Helper()
require.Eventually(t, func() bool {
_, err := os.Stat(path)
return err == nil
}, 5*time.Second, 25*time.Millisecond, msgAndArgs...)
}

// TestBackgroundWorkerLifecycle boots a background worker that touches a
// sentinel file then parks on the stop pipe. It proves the bg worker runs
// (sentinel appears) and that Shutdown returns within a reasonable time.
// The test asserts on Shutdown timing, so it manages Shutdown itself
// instead of using initServers' t.Cleanup hook.
func TestBackgroundWorkerLifecycle(t *testing.T) {
tmp := t.TempDir()
sentinel := filepath.Join(tmp, "bg-lifecycle.sentinel")

require.NoError(t, frankenphp.Init(
frankenphp.WithWorkers("bg-lifecycle", "testdata/bgworker/basic.php", 1,
frankenphp.WithWorkerBackground(),
frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}),
),
frankenphp.WithNumThreads(2),
))

requireFileEventually(t, sentinel, "background worker did not touch sentinel")

done := make(chan struct{})
go func() {
frankenphp.Shutdown()
close(done)
}()

select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatalf("Shutdown did not return within 10s")
}
}

// TestBackgroundWorkerCrashRestarts boots a worker that exit(1)s on its
// first run and touches a "restarted" sentinel on its second run. The
// sentinel proves the crash-restart loop fired.
func TestBackgroundWorkerCrashRestarts(t *testing.T) {
tmp := t.TempDir()
crashMarker := filepath.Join(tmp, "bg-crash.marker")
restarted := filepath.Join(tmp, "bg-crash.restarted")

initServers(t,
frankenphp.WithWorkers("bg-crash", "testdata/bgworker/crash.php", 1,
frankenphp.WithWorkerBackground(),
frankenphp.WithWorkerEnv(map[string]string{
"BG_CRASH_MARKER": crashMarker,
"BG_RESTARTED_SENTINEL": restarted,
}),
),
frankenphp.WithNumThreads(2),
)

requireFileEventually(t, restarted, "background worker did not restart after crash")
}

// TestBackgroundWorkerOnServer scopes a background worker to a Server. It
// proves that the worker inherits the server env (the sentinel directory is
// declared on the server, not on the worker), that FRANKENPHP_WORKER holds
// the worker name, and that the worker does not intercept HTTP requests
// served by the same server.
func TestBackgroundWorkerOnServer(t *testing.T) {
tmp := t.TempDir()

server, err := frankenphp.NewServer("sidekick-server", testDataDir, nil, map[string]string{"BG_SENTINEL_DIR": tmp}, nil)
require.NoError(t, err)

initServers(t,
frankenphp.WithServer(server),
frankenphp.WithWorkers("jobs", "testdata/bgworker/named.php", 1,
frankenphp.WithWorkerBackground(),
frankenphp.WithWorkerServerScope(server),
),
frankenphp.WithNumThreads(2),
)

// named.php touches "<BG_SENTINEL_DIR>/<FRANKENPHP_WORKER>"
requireFileEventually(t, filepath.Join(tmp, "jobs"), "background worker did not touch its per-name sentinel")

body := serverGet(t, server, "http://example.com/index.php")
assert.Contains(t, body, "I am by birth a Genevese", "the server must still serve regular requests")
}

// TestBackgroundWorkerValidation covers the declaration-time errors.
func TestBackgroundWorkerValidation(t *testing.T) {
t.Cleanup(frankenphp.Shutdown)

t.Run("name is required", func(t *testing.T) {
err := frankenphp.Init(
frankenphp.WithWorkers("", "testdata/bgworker/basic.php", 1, frankenphp.WithWorkerBackground()),
frankenphp.WithNumThreads(2),
)
require.ErrorContains(t, err, "must have an explicit name")
})

t.Run("num must be >= 1", func(t *testing.T) {
err := frankenphp.Init(
frankenphp.WithWorkers("bg-zero", "testdata/bgworker/basic.php", 0, frankenphp.WithWorkerBackground()),
frankenphp.WithNumThreads(2),
)
require.ErrorContains(t, err, "must declare num >= 1")
})

t.Run("names are global", func(t *testing.T) {
err := frankenphp.Init(
frankenphp.WithWorkers("bg-shared", "testdata/bgworker/basic.php", 1, frankenphp.WithWorkerBackground()),
frankenphp.WithWorkers("bg-shared", "testdata/bgworker/named.php", 1, frankenphp.WithWorkerBackground()),
frankenphp.WithNumThreads(3),
)
require.ErrorContains(t, err, "two workers cannot have the same name")
})

t.Run("request matchers are rejected", func(t *testing.T) {
err := frankenphp.Init(
frankenphp.WithWorkers("bg-matched", "testdata/bgworker/basic.php", 1,
frankenphp.WithWorkerBackground(),
frankenphp.WithWorkerMatcher(func(*http.Request) bool { return true }),
),
frankenphp.WithNumThreads(2),
)
require.ErrorContains(t, err, "cannot match requests")
})
}
54 changes: 54 additions & 0 deletions caddy/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,3 +269,57 @@ func TestCreateUniqueWorkerNamesQualifiedByServer(t *testing.T) {
// workers without a server keep the numeric postfix behavior
require.Equal(t, "queue_2", app.createUniqueWorkerName(wc, ""))
}

func TestWorkerBackgroundConfig(t *testing.T) {
d := caddyfile.NewTestDispenser(`
{
php_server {
worker {
name jobs
file ../testdata/worker-with-env.php
num 2
background
}
}
}`)
module := &FrankenPHPModule{}

require.NoError(t, module.UnmarshalCaddyfile(d))
require.Len(t, module.Workers, 1)
require.True(t, module.Workers[0].Background)
require.Equal(t, "jobs", module.Workers[0].Name)
}

func TestWorkerBackgroundRequiresName(t *testing.T) {
d := caddyfile.NewTestDispenser(`
{
php_server {
worker {
file ../testdata/worker-with-env.php
background
}
}
}`)
module := &FrankenPHPModule{}

err := module.UnmarshalCaddyfile(d)
require.ErrorContains(t, err, `background workers must have an explicit "name"`)
}

func TestWorkerBackgroundRejectsMatch(t *testing.T) {
d := caddyfile.NewTestDispenser(`
{
php_server {
worker {
name jobs
file ../testdata/worker-with-env.php
match /jobs/*
background
}
}
}`)
module := &FrankenPHPModule{}

err := module.UnmarshalCaddyfile(d)
require.ErrorContains(t, err, `"match" is not supported for background workers`)
}
19 changes: 18 additions & 1 deletion caddy/workerconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ type workerConfig struct {
MatchPath []string `json:"match_path,omitempty"`
// MaxConsecutiveFailures sets the maximum number of consecutive failures before panicking (defaults to 6, set to -1 to never panick)
MaxConsecutiveFailures int `json:"max_consecutive_failures,omitempty"`
// Background marks this worker as a background (non-HTTP) worker.
Background bool `json:"background,omitempty"`

options []frankenphp.WorkerOption
requestOptions []frankenphp.RequestOption
Expand Down Expand Up @@ -140,15 +142,26 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) {
}

wc.MaxConsecutiveFailures = v
case "background":
wc.Background = true
default:
return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads", v)
return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads, background", v)
}
}

if wc.FileName == "" {
return wc, d.Err(`the "file" argument must be specified`)
}

if wc.Background {
if wc.Name == "" {
return wc, d.Err(`background workers must have an explicit "name"`)
}
if len(wc.MatchPath) != 0 {
return wc, d.Err(`"match" is not supported for background workers`)
}
}

if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) {
wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName)
}
Expand All @@ -165,6 +178,10 @@ func (wc *workerConfig) toWorkerOptions() []frankenphp.WorkerOption {
frankenphp.WithWorkerRequestOptions(wc.requestOptions...),
}

if wc.Background {
opts = append(opts, frankenphp.WithWorkerBackground())
}

// copy the caddy match logic and create a unique matcher function for this worker
// inject the matcher into frankenphp
if len(wc.MatchPath) > 0 {
Expand Down
2 changes: 2 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ You can also explicitly configure FrankenPHP using the [global option](https://c
watch <path> # Sets the path to watch for file changes. Can be specified more than once for multiple paths.
name <name> # Sets the name of the worker, used in logs and metrics. Default: absolute path of worker file
max_consecutive_failures <num> # Sets the maximum number of consecutive failures before the worker is considered unhealthy, -1 means the worker will always restart. Default: 6.
background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker; it runs the script in a loop without serving requests, "name" is required, "match" is not allowed.
}
}
}
Expand Down Expand Up @@ -197,6 +198,7 @@ php_server [<matcher>] {
watch <path> # Sets the path to watch for file changes. Can be specified more than once for multiple paths.
env <key> <value> # Sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables. Environment variables for this worker are also inherited from the php_server parent, but can be overwritten here.
match <path> # match the worker to a path pattern. Overrides try_files and can only be used in the php_server directive.
background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker; it runs the script in a loop without serving requests, "name" is required, "match" is not allowed.
}
worker <other_file> <num> # Can also use the short form like in the global frankenphp block.
}
Expand Down
Loading