mirror of
https://github.com/php/frankenphp.git
synced 2026-03-24 00:52:11 +01:00
Previous behaviour was bugged from somewhere between 1.11.3 and 1.12.0:
```bash
❯❯ frankenphp git:(main) 10:46 ./frankenphp php-server
2026/03/08 03:46:36.541 WARN admin admin endpoint disabled
2026/03/08 03:46:36.541 WARN http.auto_https server is listening only on the HTTP port, so no automatic HTTPS will be applied to this server {"server_name": "php", "http_port": 80}
2026/03/08 03:46:36.542 INFO tls.cache.maintenance started background certificate maintenance {"cache": "0x182aaec16500"}
2026/03/08 03:46:36.582 INFO frankenphp FrankenPHP started 🐘 {"php_version": "8.6.0-dev", "num_threads": 32, "max_threads": 32}
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x30 pc=0xb436bf]
goroutine 1 [running]:
github.com/caddyserver/caddy/v2.(*Context).Value(0x182aaedbe078?, {0x1e2d340?, 0x25e0c90?})
<autogenerated>:1 +0x1f
github.com/caddyserver/caddy/v2/modules/caddyhttp.(*extraFieldsSlogHandler).Handle(_, {_, _}, {{0xc2635a2724e776c5, 0x67b02fd, 0x3ca5000}, {0x22f7b13, 0x17}, 0x0, 0x1b7943e, ...})
/home/m/go/pkg/mod/github.com/caddyserver/caddy/v2@v2.11.2/modules/caddyhttp/logging.go:308 +0x4c
log/slog.(*Logger).logAttrs(0x182aaea2bc30, {0x260a170?, 0x182aae96ad20?}, 0x0, {0x22f7b13, 0x17}, {0x0, 0x0, 0x0})
/home/m/static-php-cli/pkgroot/x86_64-linux/go-xcaddy/src/log/slog/logger.go:276 +0x277
log/slog.(*Logger).LogAttrs(...)
/home/m/static-php-cli/pkgroot/x86_64-linux/go-xcaddy/src/log/slog/logger.go:194
github.com/dunglas/frankenphp/caddy.(*FrankenPHPApp).Stop(0x182aae844300)
/home/m/frankenphp/caddy/app.go:182 +0x14b
github.com/caddyserver/caddy/v2.run.func2(...)
/home/m/go/pkg/mod/github.com/caddyserver/caddy/v2@v2.11.2/caddy.go:460
github.com/caddyserver/caddy/v2.run(0x25d1e48?, 0x1)
/home/m/go/pkg/mod/github.com/caddyserver/caddy/v2@v2.11.2/caddy.go:471 +0x977
github.com/caddyserver/caddy/v2.unsyncedDecodeAndRun({0x182aaee76400, 0x3ad, 0x400}, 0x1)
/home/m/go/pkg/mod/github.com/caddyserver/caddy/v2@v2.11.2/caddy.go:364 +0x17a
github.com/caddyserver/caddy/v2.changeConfig({0x22c19cb, 0x4}, {0x22c7b94, 0x7}, {0x182aaee76000, 0x3ad, 0x400}, {0x0, 0x0}, 0x1)
/home/m/go/pkg/mod/github.com/caddyserver/caddy/v2@v2.11.2/caddy.go:248 +0x959
github.com/caddyserver/caddy/v2.Load({0x182aaee76000, 0x3ad, 0x400}, 0x1)
/home/m/go/pkg/mod/github.com/caddyserver/caddy/v2@v2.11.2/caddy.go:137 +0x225
github.com/caddyserver/caddy/v2.Run(0x1fdb7a0?)
/home/m/go/pkg/mod/github.com/caddyserver/caddy/v2@v2.11.2/caddy.go:109 +0x3b
github.com/dunglas/frankenphp/caddy.cmdPHPServer({0x0?})
/home/m/frankenphp/caddy/php-server.go:320 +0x27b6
github.com/dunglas/frankenphp/caddy.init.4.func1.WrapCommandFuncForCobra.1(0x182aaee53808, {0x22c1a77?, 0x4?, 0x22c1a03?})
/home/m/go/pkg/mod/github.com/caddyserver/caddy/v2@v2.11.2/cmd/cobra.go:151 +0x2f
github.com/spf13/cobra.(*Command).execute(0x182aaee53808, {0x3cc96a0, 0x0, 0x0})
/home/m/go/pkg/mod/github.com/spf13/cobra@v1.10.2/command.go:1015 +0xb14
github.com/spf13/cobra.(*Command).ExecuteC(0x182aae7fbb08)
/home/m/go/pkg/mod/github.com/spf13/cobra@v1.10.2/command.go:1148 +0x465
github.com/spf13/cobra.(*Command).Execute(...)
/home/m/go/pkg/mod/github.com/spf13/cobra@v1.10.2/command.go:1071
github.com/caddyserver/caddy/v2/cmd.Main()
/home/m/go/pkg/mod/github.com/caddyserver/caddy/v2@v2.11.2/cmd/main.go:72 +0x65
main.main()
/home/m/frankenphp/caddy/frankenphp/main.go:14 +0xf
```
This restores it to exit cleanly again:
```bash
/home/m/frankenphp/caddy/frankenphp/main.go:14 +0xf
❯❯ frankenphp git:(main) 10:46 go build
❯❯ frankenphp git:(main) 10:58 ./frankenphp php-server
2026/03/08 03:58:01.533 WARN admin admin endpoint disabled
2026/03/08 03:58:01.533 WARN http.auto_https server is listening only on the HTTP port, so no automatic HTTPS will be applied to this server {"server_name": "php", "http_port": 80}
2026/03/08 03:58:01.533 INFO tls.cache.maintenance started background certificate maintenance {"cache": "0x850ebf79280"}
2026/03/08 03:58:01.556 INFO tls.cache.maintenance stopped background certificate maintenance {"cache": "0x850ebf79280"}
2026/03/08 03:58:01.556 INFO http servers shutting down with eternal grace period
Error: loading new config: http app module: start: listening on :80: listen tcp :80: bind: permission denied
```
343 lines
8.7 KiB
Go
343 lines
8.7 KiB
Go
package caddy
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/caddyserver/caddy/v2"
|
|
"github.com/caddyserver/caddy/v2/caddyconfig"
|
|
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
|
|
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
|
|
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
|
"github.com/dunglas/frankenphp"
|
|
"github.com/dunglas/frankenphp/internal/fastabs"
|
|
)
|
|
|
|
var (
|
|
options []frankenphp.Option
|
|
optionsMU sync.RWMutex
|
|
)
|
|
|
|
// EXPERIMENTAL: RegisterWorkers provides a way for extensions to register frankenphp.Workers
|
|
func RegisterWorkers(name, fileName string, num int, wo ...frankenphp.WorkerOption) frankenphp.Workers {
|
|
w, opt := frankenphp.WithExtensionWorkers(name, fileName, num, wo...)
|
|
|
|
optionsMU.Lock()
|
|
options = append(options, opt)
|
|
optionsMU.Unlock()
|
|
|
|
return w
|
|
}
|
|
|
|
// FrankenPHPApp represents the global "frankenphp" directive in the Caddyfile
|
|
// it's responsible for starting up the global PHP instance and all threads
|
|
//
|
|
// {
|
|
// frankenphp {
|
|
// num_threads 20
|
|
// }
|
|
// }
|
|
type FrankenPHPApp struct {
|
|
// NumThreads sets the number of PHP threads to start. Default: 2x the number of available CPUs.
|
|
NumThreads int `json:"num_threads,omitempty"`
|
|
// MaxThreads limits how many threads can be started at runtime. Default 2x NumThreads
|
|
MaxThreads int `json:"max_threads,omitempty"`
|
|
// Workers configures the worker scripts to start
|
|
Workers []workerConfig `json:"workers,omitempty"`
|
|
// Overwrites the default php ini configuration
|
|
PhpIni map[string]string `json:"php_ini,omitempty"`
|
|
// The maximum amount of time a request may be stalled waiting for a thread
|
|
MaxWaitTime time.Duration `json:"max_wait_time,omitempty"`
|
|
// The maximum amount of time an autoscaled thread may be idle before being deactivated
|
|
MaxIdleTime time.Duration `json:"max_idle_time,omitempty"`
|
|
|
|
opts []frankenphp.Option
|
|
metrics frankenphp.Metrics
|
|
ctx context.Context
|
|
logger *slog.Logger
|
|
}
|
|
|
|
var iniError = errors.New(`"php_ini" must be in the format: php_ini "<key>" "<value>"`)
|
|
|
|
// CaddyModule returns the Caddy module information.
|
|
func (f FrankenPHPApp) CaddyModule() caddy.ModuleInfo {
|
|
return caddy.ModuleInfo{
|
|
ID: "frankenphp",
|
|
New: func() caddy.Module { return &f },
|
|
}
|
|
}
|
|
|
|
// Provision sets up the module.
|
|
func (f *FrankenPHPApp) Provision(ctx caddy.Context) error {
|
|
f.ctx = ctx
|
|
f.logger = ctx.Slogger()
|
|
|
|
// We have at least 7 hardcoded options
|
|
f.opts = make([]frankenphp.Option, 0, 7+len(options))
|
|
|
|
if httpApp, err := ctx.AppIfConfigured("http"); err == nil {
|
|
if httpApp.(*caddyhttp.App).Metrics != nil {
|
|
f.metrics = frankenphp.NewPrometheusMetrics(ctx.GetMetricsRegistry())
|
|
}
|
|
} else {
|
|
// if the http module is not configured (this should never happen) then collect the metrics by default
|
|
if errors.Is(err, caddy.ErrNotConfigured) {
|
|
f.metrics = frankenphp.NewPrometheusMetrics(ctx.GetMetricsRegistry())
|
|
} else {
|
|
// the http module failed to provision due to invalid configuration
|
|
return fmt.Errorf("failed to provision caddy http: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (f *FrankenPHPApp) generateUniqueModuleWorkerName(filepath string) string {
|
|
var i uint
|
|
filepath, _ = fastabs.FastAbs(filepath)
|
|
name := "m#" + filepath
|
|
|
|
retry:
|
|
for _, wc := range f.Workers {
|
|
if wc.Name == name {
|
|
name = fmt.Sprintf("m#%s_%d", filepath, i)
|
|
i++
|
|
|
|
goto retry
|
|
}
|
|
}
|
|
|
|
return name
|
|
}
|
|
|
|
func (f *FrankenPHPApp) addModuleWorkers(workers ...workerConfig) ([]workerConfig, error) {
|
|
for i := range workers {
|
|
w := &workers[i]
|
|
|
|
if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(w.FileName) {
|
|
w.FileName = filepath.Join(frankenphp.EmbeddedAppPath, w.FileName)
|
|
}
|
|
|
|
if w.Name == "" {
|
|
w.Name = f.generateUniqueModuleWorkerName(w.FileName)
|
|
} else if !strings.HasPrefix(w.Name, "m#") {
|
|
w.Name = "m#" + w.Name
|
|
}
|
|
|
|
f.Workers = append(f.Workers, *w)
|
|
}
|
|
|
|
return workers, nil
|
|
}
|
|
|
|
func (f *FrankenPHPApp) Start() error {
|
|
repl := caddy.NewReplacer()
|
|
|
|
optionsMU.RLock()
|
|
f.opts = append(f.opts, options...)
|
|
optionsMU.RUnlock()
|
|
|
|
f.opts = append(f.opts,
|
|
frankenphp.WithContext(f.ctx),
|
|
frankenphp.WithLogger(f.logger),
|
|
frankenphp.WithNumThreads(f.NumThreads),
|
|
frankenphp.WithMaxThreads(f.MaxThreads),
|
|
frankenphp.WithMetrics(f.metrics),
|
|
frankenphp.WithPhpIni(f.PhpIni),
|
|
frankenphp.WithMaxWaitTime(f.MaxWaitTime),
|
|
frankenphp.WithMaxIdleTime(f.MaxIdleTime),
|
|
)
|
|
|
|
for _, w := range f.Workers {
|
|
w.options = append(w.options,
|
|
frankenphp.WithWorkerEnv(w.Env),
|
|
frankenphp.WithWorkerWatchMode(w.Watch),
|
|
frankenphp.WithWorkerMaxFailures(w.MaxConsecutiveFailures),
|
|
frankenphp.WithWorkerMaxThreads(w.MaxThreads),
|
|
frankenphp.WithWorkerRequestOptions(w.requestOptions...),
|
|
)
|
|
|
|
f.opts = append(f.opts, frankenphp.WithWorkers(w.Name, repl.ReplaceKnown(w.FileName, ""), w.Num, w.options...))
|
|
}
|
|
|
|
frankenphp.Shutdown()
|
|
if err := frankenphp.Init(f.opts...); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (f *FrankenPHPApp) Stop() error {
|
|
if f.logger.Enabled(f.ctx, slog.LevelInfo) {
|
|
f.logger.LogAttrs(f.ctx, slog.LevelInfo, "FrankenPHP stopped 🐘")
|
|
}
|
|
|
|
// attempt a graceful shutdown if caddy is exiting
|
|
// note: Exiting() is currently marked as 'experimental'
|
|
// https://github.com/caddyserver/caddy/blob/e76405d55058b0a3e5ba222b44b5ef00516116aa/caddy.go#L810
|
|
if caddy.Exiting() {
|
|
frankenphp.Shutdown()
|
|
}
|
|
|
|
// reset the configuration so it doesn't bleed into later tests
|
|
f.Workers = nil
|
|
f.NumThreads = 0
|
|
f.MaxWaitTime = 0
|
|
f.MaxIdleTime = 0
|
|
|
|
optionsMU.Lock()
|
|
options = nil
|
|
optionsMU.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
|
|
func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
|
|
for d.Next() {
|
|
for d.NextBlock(0) {
|
|
// when adding a new directive, also update the allowedDirectives error message
|
|
switch d.Val() {
|
|
case "num_threads":
|
|
if !d.NextArg() {
|
|
return d.ArgErr()
|
|
}
|
|
|
|
v, err := strconv.ParseUint(d.Val(), 10, 32)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
f.NumThreads = int(v)
|
|
case "max_threads":
|
|
if !d.NextArg() {
|
|
return d.ArgErr()
|
|
}
|
|
|
|
if d.Val() == "auto" {
|
|
f.MaxThreads = -1
|
|
continue
|
|
}
|
|
|
|
v, err := strconv.ParseUint(d.Val(), 10, 32)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
f.MaxThreads = int(v)
|
|
case "max_wait_time":
|
|
if !d.NextArg() {
|
|
return d.ArgErr()
|
|
}
|
|
|
|
v, err := time.ParseDuration(d.Val())
|
|
if err != nil {
|
|
return d.Err("max_wait_time must be a valid duration (example: 10s)")
|
|
}
|
|
|
|
f.MaxWaitTime = v
|
|
case "max_idle_time":
|
|
if !d.NextArg() {
|
|
return d.ArgErr()
|
|
}
|
|
|
|
v, err := time.ParseDuration(d.Val())
|
|
if err != nil {
|
|
return d.Err("max_idle_time must be a valid duration (example: 30s)")
|
|
}
|
|
|
|
f.MaxIdleTime = v
|
|
case "php_ini":
|
|
parseIniLine := func(d *caddyfile.Dispenser) error {
|
|
key := d.Val()
|
|
if !d.NextArg() {
|
|
return d.WrapErr(iniError)
|
|
}
|
|
if f.PhpIni == nil {
|
|
f.PhpIni = make(map[string]string)
|
|
}
|
|
f.PhpIni[key] = d.Val()
|
|
if d.NextArg() {
|
|
return d.WrapErr(iniError)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
isBlock := false
|
|
for d.NextBlock(1) {
|
|
isBlock = true
|
|
err := parseIniLine(d)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !isBlock {
|
|
if !d.NextArg() {
|
|
return d.WrapErr(iniError)
|
|
}
|
|
err := parseIniLine(d)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
case "worker":
|
|
wc, err := unmarshalWorker(d)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) {
|
|
wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName)
|
|
}
|
|
if strings.HasPrefix(wc.Name, "m#") {
|
|
return d.Errf(`global worker names must not start with "m#": %q`, wc.Name)
|
|
}
|
|
// check for duplicate workers
|
|
for _, existingWorker := range f.Workers {
|
|
if existingWorker.FileName == wc.FileName {
|
|
return d.Errf("global workers must not have duplicate filenames: %q", wc.FileName)
|
|
}
|
|
}
|
|
|
|
f.Workers = append(f.Workers, wc)
|
|
default:
|
|
return wrongSubDirectiveError("frankenphp", "num_threads, max_threads, php_ini, worker, max_wait_time, max_idle_time", d.Val())
|
|
}
|
|
}
|
|
}
|
|
|
|
if f.MaxThreads > 0 && f.NumThreads > 0 && f.MaxThreads < f.NumThreads {
|
|
return d.Err(`"max_threads"" must be greater than or equal to "num_threads"`)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func parseGlobalOption(d *caddyfile.Dispenser, _ any) (any, error) {
|
|
app := &FrankenPHPApp{}
|
|
if err := app.UnmarshalCaddyfile(d); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// tell Caddyfile adapter that this is the JSON for an app
|
|
return httpcaddyfile.App{
|
|
Name: "frankenphp",
|
|
Value: caddyconfig.JSON(app, nil),
|
|
}, nil
|
|
}
|
|
|
|
var (
|
|
_ caddy.App = (*FrankenPHPApp)(nil)
|
|
_ caddy.Provisioner = (*FrankenPHPApp)(nil)
|
|
)
|