Skip to content

feat: validate a configuration before it replaces the running one - #2661

Open
nicolas-grekas wants to merge 6 commits into
php:mainfrom
nicolas-grekas:config-preflight
Open

nicolas-grekas wants to merge 6 commits into
php:mainfrom
nicolas-grekas:config-preflight

Conversation

@nicolas-grekas

Copy link
Copy Markdown
Contributor

Start() calls Shutdown() before Init(), since the PHP runtime is a process singleton, so a declaration error takes the site down: Caddy rolls back the configuration, not the runtime that went with it, and the server answers 500 until the next reload. A missing worker file does it, so does a name two workers share, or a thread budget that does not add up.

frankenphp.Validate() takes the options Init() takes and reports what it would refuse, touching nothing: the thread budget, the worker files, and the names and scopes workers may take. The rules are the ones Init() runs, newWorker() now shares them instead of holding its own copy.

The Caddy app implements caddy.Validator on top of it, which Caddy calls while the previous configuration still serves:

POST /load with a worker file that does not exist
{"error":"loading config: ... frankenphp: invalid configuration: worker filename is invalid \"...\": no such file or directory"}

and the site that was running keeps serving, which TestRejectedReloadKeepsThePreviousSiteServing checks by counting the requests its worker served across the rejected reload.

Collecting the options moved out of Start() for that, and the names workers take are uniquified per collection rather than per app, so validating a configuration no longer changes the names the next one gets.

Reported by @henderkes in #2617, where duplicate worker names added a trigger to a failure mode that predates it: #2617 (comment)

Comment thread caddy/app.go
Comment thread caddy/app.go
Comment thread worker.go
@henderkes

Copy link
Copy Markdown
Contributor

Also, I did not read the comments during this review... too many comments, too many of them useless.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Validation misses per-server duplicate worker files, and worker initialization now has quadratic map-copying overhead.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 2 Medium severity

Open (2)
What changed in this PR

Adds preflight configuration validation so invalid Caddy reloads do not stop the running PHP runtime.

Changes:

  • Adds frankenphp.Validate() and shared worker validation.
  • Implements Caddy’s configuration validator.
  • Adds unit and reload regression tests.
File Description
worker.go Extracts worker file and declaration validation.
frankenphp.go Adds Validate() and defers worker metrics.
frankenphp_test.go Tests declaration validation errors.
docs/​library.md Documents the validation API.
caddy/​app.go Adds validation and refactors option collection.
caddy/​admin_test.go Tests rejected reload continuity.
caddy/​config_test.go Updates worker-name tests.
caddy/​serveridx_test.go Updates server collection tests.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread frankenphp.go Outdated
Comment thread worker.go Outdated
@nicolas-grekas

Copy link
Copy Markdown
Contributor Author

Review addressed in a438dce, CI green.

You were right about both: configureHotReload adds its option while the modules provision and Start() collected the configuration over it, which was the red CI, and Validate() runs before those modules exist, so the workers a php_server declares were never part of what it checked. Provisioning-time options have their own slice now, and Start() runs the same rules over the options it collected before it shuts anything down, so a bad declaration leaves the running configuration alone whichever block it came from. TestRejectedReloadWithAModuleWorkerKeepsThePreviousSiteServing fails without that, with the 500 you would get today.

The declaration rules take lookups instead of copies of every name and path before them, per Copilot.

And the comments: fair, several restated the function under them. They are down from 49 lines to 35, the ones left say why.

Comment thread caddy/admin_test.go Outdated

@henderkes henderkes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please unsloppify the comments further. No AI is going to get that done.

Comment thread caddy/admin_test.go Outdated
Comment thread caddy/app.go
Comment thread caddy/app.go Outdated
@nicolas-grekas

Copy link
Copy Markdown
Contributor Author

Please unsloppify the comments further. No AI is going to get that done.

I'd appreciate your guidance on this aspect, there aren't that many comment now.

Start() calls Shutdown() before Init(), since the PHP runtime is a process
singleton, so a declaration error takes the site down and Caddy rolls back
the configuration, not the runtime that went with it: a missing worker
file, a name two workers share, a thread budget that does not add up, any
of them leaves the server answering 500 until the next reload.

frankenphp.Validate() takes the options Init() takes and reports what it
would refuse, touching nothing: the thread budget, the worker files, and
the names and scopes workers may take. The rules are the ones Init() runs,
newWorker() now shares them rather than holding its own copy.

The Caddy app implements caddy.Validator on it, which Caddy calls while
the previous configuration still serves, so a rejected reload keeps the
site up. Collecting the options moved out of Start() for that, and the
names workers take are uniquified per collection rather than per app, so
validating a configuration does not change the names the next one gets.
Caddy calls Validate() while it provisions the app, before the modules
that carry php_server blocks provision themselves, so the workers they
declare were not part of what it checked: a bad one passed validation and
failed inside Init(), once Shutdown() had already taken the runtime that
was serving. The site then answered "server is not registered" until the
next reload, which is the failure this PR is about.

Start() now runs the same rules over the options it collected, before it
shuts anything down, so a rejected configuration leaves the running one
alone whichever block declared the worker.

Also fixes two things the review found: the options a module adds while it
provisions, the hot reload among them, were dropped by Start() collecting
the configuration over them, and they now have a slice of their own; and
the declaration rules take lookups rather than copies of every name and
path taken before, which made a declaration cost the ones before it.
Marc read none of them, which is fair: several restated the name of the
function under them. The ones that carry a reason stay, shorter.
@henderkes

Copy link
Copy Markdown
Contributor

I'll look later.

@henderkes
henderkes requested a balanced review from Copilot September 21, 2026 16:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

Review effort: Lite
Findings: 1 High severity · 2 Medium severity · 1 Low severity

Open (4)
Resolved since last review (2)

Comment thread caddy/app.go Outdated
Comment thread caddy/admin_test.go
Comment thread frankenphp.go Outdated
Comment thread caddy/admin_test.go Outdated
@nicolas-grekas

Copy link
Copy Markdown
Contributor Author

Copilot review should be addressed + another issue I found with Validate() addressed also.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants