Skip to content

Commit 0f13ac6

Browse files
fix: review follow-ups on the php_server refactor (#2565)
Follow-up to #2499, targeting `refactor/phpserver`. Four commits, each independently reviewable. Findings from a close read of the branch. Nothing here changes the direction of the refactor; the first two are the ones I would not want to ship. ### 1. The per-worker Mercure hub is dropped `assignMercureHub()` fills `workerConfig.options`, but the new `toWorkerOptions()` builds a fresh slice and never appends it, where `Start()` used to pass `w.options` directly. The field is now written and never read. `WithWorkerMercureHub()` appends `WithMercureHub()` to the worker request options, which is what lets a worker publish from its startup code, outside `frankenphp_handle_request()`. Request-scoped publishing kept working because the module carries `WithMercureHub()` in its own request options, which is why no test noticed. #2543 extends the same function and inherits the bug. ### 2. `FrankenPHPModule.server` can be nil in `ServeHTTP()` It is assigned by `FrankenPHPApp.Start()`, but caddy starts apps by ranging over a map, so the http app can begin serving first. The handler dereferenced it unconditionally, turning that window into a nil pointer panic where `main` returned a clean `ErrNotRunning`. Two neighbouring lifecycle issues are fixed in the same commit: modules left in `app.modules` by a config that failed to provision are registered again by the next reload (the app is a process singleton and `reset()` only runs at the end of `Start()`), and `match` in a global worker block was parsed then silently ignored, so it is now rejected at parse time as `docs/config.md` already documents. ### 3. A `Server` could not be registered twice `unregisterServers()` only flipped `isRegistered`, so the worker slices and maps kept the previous run's entries and a second `Init()` with the same `*Server` failed with `two workers in a server cannot have the same filename`. Caddy dodges this by building a fresh `NewServer` per `Start()`, but `docs/library.md` presents `NewServer` + `Init` as the library pattern with no hint the instance is single-use. Same commit: the `server_<idx>` default was written into `s.name` permanently, servers were marked registered about a hundred lines before `initWorkers()` and the thread setup (a request in that window found neither a worker nor a regular thread, so `activateServers()` is split out), and `WithWorkerMatcher()` without `WithWorkerServerScope()` was a silent no-op that still got path-matched via `globalWorkersByPath`, the opposite of what the matcher asked for. ### 4. Logger and prepared env Worker startup contexts hardcoded `globalLogger` even though the worker's server was at hand, so worker boot messages and worker stdout bypassed the per-`php_server` logger this branch introduces. The module used to append `WithRequestLogger()` to each worker's request options and that line is gone, so nothing else covered it. `go_register_server_variables()` merged the prepared env whenever the server had one, but `registerPreparedEnv()` only runs from `go_update_request_info()`, which returns early without a request. For a server-scoped extension worker handling a message the merge copied whatever the thread-local prepared env held from an earlier request. ### Tests Added: `toWorkerOptions()` keeps provisioned options, a `Server` re-registered after `Shutdown()` still serves its worker and keeps its name, and a request matcher without a server scope is rejected. Both modules pass `go vet` including test files. The runtime tests need CI: on my box (WSL2) per-thread engine bootstrap of an embed ZTS build is pathologically slow and linking needs dev libs I do not have, so every `Init()`-based test is unrunnable locally, including on unmodified base commits. ### Not included `worker.mercureHub` is assigned by `configureMercure()` and never read anywhere; removing the dead field touches the `nomercure` build-tag pair, so I left it alone. Say the word and I will fold it in.
1 parent b72328c commit 0f13ac6

11 files changed

Lines changed: 142 additions & 40 deletions

File tree

caddy/app.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ func (f *FrankenPHPApp) Provision(ctx caddy.Context) error {
8383
f.ctx = ctx
8484
f.logger = ctx.Slogger()
8585

86+
// drop the modules of a config that failed to provision, reset() only runs in Start()
87+
f.modules = nil
88+
f.usedWorkerNames = nil
89+
8690
// We have at least 7 hardcoded options
8791
f.opts = make([]frankenphp.Option, 0, 7+len(options))
8892

@@ -371,6 +375,10 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
371375
if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) {
372376
wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName)
373377
}
378+
// a global worker has no php_server, so no set of requests to match against
379+
if len(wc.MatchPath) != 0 {
380+
return d.Errf(`"match" can only be used in a php_server worker block, not in a global one: %q`, wc.FileName)
381+
}
374382
// check for duplicate workers
375383
for _, existingWorker := range f.Workers {
376384
if existingWorker.FileName == wc.FileName {

caddy/config_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,20 @@ import (
77

88
"github.com/caddyserver/caddy/v2"
99
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
10+
"github.com/dunglas/frankenphp"
1011
"github.com/stretchr/testify/require"
1112
)
1213

14+
// options collected while provisioning a module, like the Mercure hub, must reach frankenphp
15+
func TestWorkerOptionsKeepProvisionedOptions(t *testing.T) {
16+
wc := workerConfig{FileName: "../testdata/worker-with-env.php"}
17+
base := len(wc.toWorkerOptions())
18+
19+
wc.options = append(wc.options, frankenphp.WithWorkerMaxThreads(3))
20+
21+
require.Len(t, wc.toWorkerOptions(), base+1, "worker options set during provisioning must be forwarded")
22+
}
23+
1324
func TestModuleRequestBodyTimeout(t *testing.T) {
1425
d := caddyfile.NewTestDispenser(`
1526
{

caddy/module.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ type FrankenPHPModule struct {
4747
Env map[string]string `json:"env,omitempty"`
4848
// Workers configures the worker scripts to start.
4949
Workers []workerConfig `json:"workers,omitempty"`
50-
// ServerIdx is the idx of the php_server this module belongs to
50+
// ServerIdx is set automatically to pair the route embeds of one php_server directive. Do not set it manually: modules sharing an index share one server, defined by the first of them.
5151
ServerIdx int `json:"server_idx,omitempty"`
5252
// RequestBodyTimeout is an idle timeout on request body reads: a stalled (slow POST) client is cut off while a steady upload of any size succeeds. Defaults to 60s when omitted; set to 0 to disable.
5353
RequestBodyTimeout *caddy.Duration `json:"request_body_timeout,omitempty"`
@@ -188,6 +188,11 @@ func needReplacement(s string) bool {
188188

189189
// ServeHTTP implements caddyhttp.MiddlewareHandler.
190190
func (f *FrankenPHPModule) ServeHTTP(w http.ResponseWriter, r *http.Request, _ caddyhttp.Handler) error {
191+
// the server is assigned in FrankenPHPApp.Start(), which caddy may run after the http app started serving
192+
if f.server == nil {
193+
return caddyhttp.Error(http.StatusInternalServerError, frankenphp.ErrNotRunning)
194+
}
195+
191196
ctx := r.Context()
192197
repl := ctx.Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
193198

caddy/workerconfig.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ func (wc *workerConfig) toWorkerOptions() []frankenphp.WorkerOption {
165165
frankenphp.WithWorkerRequestOptions(wc.requestOptions...),
166166
}
167167

168+
// options collected while provisioning the module, e.g. the Mercure hub
169+
opts = append(opts, wc.options...)
170+
168171
// copy the caddy match logic and create a unique matcher function for this worker
169172
// inject the matcher into frankenphp
170173
if len(wc.MatchPath) > 0 {

cgi.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,11 +171,14 @@ func go_register_server_variables(threadIndex C.uintptr_t, trackVarsArray *C.zva
171171
thread := phpThreads[threadIndex]
172172
fc := thread.handler.frankenPHPContext()
173173

174-
if fc.request != nil {
175-
addKnownVariablesToServer(fc, trackVarsArray)
176-
addHeadersToServer(fc.ctx, fc.request, trackVarsArray)
174+
if fc.request == nil {
175+
// go_update_request_info() never ran, the thread-local prepared env still holds the previous request
176+
return
177177
}
178178

179+
addKnownVariablesToServer(fc, trackVarsArray)
180+
addHeadersToServer(fc.ctx, fc.request, trackVarsArray)
181+
179182
// The Prepared Environment is registered last and can overwrite any previous values
180183
if len(fc.env) != 0 || len(fc.server.env) != 0 {
181184
C.frankenphp_merge_with_prepared_env(trackVarsArray)

context.go

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -122,14 +122,21 @@ func newWorkerDummyContext(w *worker) (*frankenPHPContext, error) {
122122
return nil, err
123123
}
124124

125+
server := w.server
126+
if server == nil {
127+
// global worker, not associated with a server
128+
server = fallbackServer
129+
}
130+
125131
fc := &frankenPHPContext{
126132
done: make(chan any),
127133
ctx: r.Context(),
128-
server: w.server,
134+
server: server,
129135
request: r,
130136
startedAt: time.Now(),
131-
logger: globalLogger,
132-
worker: w,
137+
// startup output of a scoped worker belongs to its server's logger
138+
logger: server.logger.Load(),
139+
worker: w,
133140
}
134141

135142
for _, o := range w.requestOptions {
@@ -138,38 +145,32 @@ func newWorkerDummyContext(w *worker) (*frankenPHPContext, error) {
138145
}
139146
}
140147

141-
if fc.server == nil {
142-
// global worker, not associated with a server
143-
fc.server = fallbackServer
144-
}
145-
146148
splitCgiPath(fc)
147149

148150
return fc, nil
149151
}
150152

151153
// newContextFromMessage creates a context from a message (external workers)
152154
func newContextFromMessage(message any, rw http.ResponseWriter, ctx context.Context, w *worker) *frankenPHPContext {
153-
fc := &frankenPHPContext{
155+
server := w.server
156+
if server == nil {
157+
server = fallbackServer
158+
}
159+
160+
if ctx == nil {
161+
ctx = globalCtx
162+
}
163+
164+
return &frankenPHPContext{
154165
done: make(chan any),
155166
startedAt: time.Now(),
156-
server: w.server,
167+
server: server,
157168
worker: w,
158-
logger: globalLogger,
169+
logger: server.logger.Load(),
159170
responseWriter: rw,
160171
handlerParameters: message,
161172
ctx: ctx,
162173
}
163-
164-
if fc.server == nil {
165-
fc.server = fallbackServer
166-
}
167-
168-
if fc.ctx == nil {
169-
fc.ctx = globalCtx
170-
}
171-
172-
return fc
173174
}
174175

175176
// closeContext sends the response to the client

docs/library.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func main() {
4646

4747
`NewServer()` takes a human-readable name used to attribute workers, metrics and logs to the server (defaults to `server_<idx>` at registration when empty), the document root, the split path suffixes (defaults to `[".php"]`), environment variables made available to every request, and a `*slog.Logger` (defaults to the global logger).
4848

49-
`Init()` starts the PHP runtime and must be called exactly once before serving requests; `Shutdown()` stops it. Calling `Server.ServeHTTP()` before `Init()` or after `Shutdown()` returns `ErrNotRunning`.
49+
`Init()` starts the PHP runtime and must be called exactly once before serving requests; `Shutdown()` stops it. Calling `Server.ServeHTTP()` before `Init()` or after `Shutdown()` returns `ErrNotRunning`. The same `*Server` may be passed to `Init()` again after a `Shutdown()`, for instance to reload the configuration.
5050

5151
## Multiple servers
5252

@@ -87,7 +87,7 @@ err := frankenphp.Init(
8787
)
8888
```
8989

90-
Workers declared without a server scope are global: they match by file path on any server.
90+
Workers declared without a server scope are global: they match by file path on any server. Since a global worker has no set of requests to match against, combining `WithWorkerMatcher()` with a global worker is a configuration error and `Init()` rejects it.
9191

9292
## Per-request options
9393

frankenphp.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,9 @@ func Init(options ...Option) error {
342342

343343
initAutoScaling(mainThread)
344344

345+
// only now that the workers and threads are up may requests reach a server
346+
activateServers()
347+
345348
if globalLogger.Enabled(globalCtx, slog.LevelInfo) {
346349
globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "FrankenPHP started 🐘", slog.String("php_version", Version().Version), slog.Int("num_threads", mainThread.numThreads), slog.Int("max_threads", mainThread.maxThreads), slog.Int("max_requests", maxRequestsPerThread))
347350

@@ -782,6 +785,8 @@ func resetGlobals() {
782785
workers = nil
783786
workersByName = nil
784787
globalWorkersByPath = nil
788+
servers = nil
789+
fallbackServer.logger.Store(globalLogger)
785790
watcherIsEnabled = false
786791
maxIdleTime = defaultMaxIdleTime
787792
maxRequestsPerThread = 0

server.go

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,16 @@ import (
1313
// Server represents a preconfigured server block
1414
// requests and workers can be scoped to a Server
1515
type Server struct {
16-
idx int
16+
idx int
17+
// name passed to NewServer(), kept so re-registering resolves the default anew
18+
configuredName string
1719
name string
1820
root string
1921
splitPath []string
2022
env PreparedEnv
2123
workers []*worker
2224
workersByPath map[string]*worker
2325
workersWithRequestMatcher []*worker
24-
workerOpts []workerOpt
2526

2627
// registered while FrankenPHP runs with this server; read by concurrent
2728
// ServeHTTP calls while Init()/Shutdown() flip it, hence atomic
@@ -48,15 +49,29 @@ func newFallbackServer() *Server {
4849
return s
4950
}
5051

52+
// registerServers assigns the identity of every server and clears the workers of a previous run,
53+
// so the same *Server can be passed to Init() again after a Shutdown()
54+
// servers do not accept requests yet at this point, see activateServers()
5155
func registerServers(newServers []*Server) {
5256
servers = newServers
5357
fallbackServer.logger.Store(globalLogger)
54-
fallbackServer.isRegistered.Store(true)
58+
fallbackServer.resetWorkers()
59+
5560
for i, s := range servers {
5661
s.idx = i
62+
s.name = s.configuredName
5763
if s.name == "" {
5864
s.name = "server_" + strconv.Itoa(i)
5965
}
66+
s.resetWorkers()
67+
}
68+
}
69+
70+
// activateServers lets registered servers accept requests
71+
// it runs once workers and threads are up, so a request cannot reach a server before them
72+
func activateServers() {
73+
fallbackServer.isRegistered.Store(true)
74+
for _, s := range servers {
6075
s.isRegistered.Store(true)
6176
}
6277
}
@@ -66,6 +81,14 @@ func unregisterServers() {
6681
for _, server := range servers {
6782
server.isRegistered.Store(false)
6883
}
84+
servers = nil
85+
}
86+
87+
// resetWorkers drops the workers of a previous run; initWorkers() adds them back
88+
func (s *Server) resetWorkers() {
89+
s.workers = nil
90+
s.workersByPath = make(map[string]*worker)
91+
s.workersWithRequestMatcher = nil
6992
}
7093

7194
// NewServer creates a Server that can be registered via WithServer().
@@ -83,12 +106,12 @@ func NewServer(name, root string, splitPath []string, env map[string]string, log
83106
}
84107

85108
s := &Server{
86-
name: name,
87-
root: root,
88-
splitPath: splitPath,
89-
env: PrepareEnv(env),
90-
workersByPath: make(map[string]*worker),
91-
workerOpts: make([]workerOpt, 0),
109+
configuredName: name,
110+
name: name,
111+
root: root,
112+
splitPath: splitPath,
113+
env: PrepareEnv(env),
114+
workersByPath: make(map[string]*worker),
92115
}
93116

94117
if logger == nil {

server_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,42 @@ func TestServer(t *testing.T) {
172172
assert.Contains(t, err.Error(), "two workers in a server cannot have the same filename")
173173
})
174174

175+
t.Run("error_on_request_matcher_without_server_scope", func(t *testing.T) {
176+
t.Cleanup(frankenphp.Shutdown)
177+
178+
err := frankenphp.Init(
179+
frankenphp.WithWorkers("global", testDataDir+"worker-with-counter.php", 1,
180+
frankenphp.WithWorkerMatcher(func(*http.Request) bool { return true }),
181+
),
182+
)
183+
184+
assert.Error(t, err)
185+
assert.Contains(t, err.Error(), "no server scope")
186+
})
187+
188+
// re-registering a Server must not trip the duplicate filename check on its own workers
189+
t.Run("reregistration_after_shutdown", func(t *testing.T) {
190+
server, err := frankenphp.NewServer("", testDataDir, nil, nil, nil)
191+
require.NoError(t, err)
192+
193+
opts := []frankenphp.Option{
194+
frankenphp.WithServer(server),
195+
frankenphp.WithWorkers("counter", testDataDir+"worker-with-counter.php", 1,
196+
frankenphp.WithWorkerServerScope(server),
197+
),
198+
}
199+
200+
initServers(t, opts...)
201+
assert.Equal(t, "requests:1", serverGet(t, server, "http://example.com/worker-with-counter.php"))
202+
assert.Equal(t, "server_0", server.Name())
203+
204+
frankenphp.Shutdown()
205+
206+
initServers(t, opts...)
207+
assert.Equal(t, "requests:1", serverGet(t, server, "http://example.com/worker-with-counter.php"))
208+
assert.Equal(t, "server_0", server.Name())
209+
})
210+
175211
t.Run("error_on_missing_registration", func(t *testing.T) {
176212
server, _ := frankenphp.NewServer("", testDataDir, nil, nil, nil)
177213

0 commit comments

Comments
 (0)