Skip to content
Merged
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
3 changes: 3 additions & 0 deletions docs/hot-reload.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ php_server {

By default, FrankenPHP will watch all files in the current working directory matching this glob pattern: `./**/*.{css,env,gif,htm,html,jpg,jpeg,js,mjs,php,png,svg,twig,webp,xml,yaml,yml}`

When hot reload is enabled, `opcache.revalidate_freq` defaults to `0`, so OPcache checks on every request whether a script changed.
Setting it in a `php.ini` file or through the `php_ini` directive takes precedence.

It's possible to set the files to watch using the glob syntax explicitly:

```caddyfile
Expand Down
13 changes: 13 additions & 0 deletions frankenphp.c
Original file line number Diff line number Diff line change
Expand Up @@ -1522,6 +1522,17 @@ static char *frankenphp_getenv(const char *name, size_t name_len) {
return NULL;
}

/* php.ini files and php_ini directives override these defaults. */
static void frankenphp_ini_defaults(HashTable *configuration_hash) {
if (go_is_hot_reload_enabled()) {
/* Otherwise, a page reloaded right after a change may run the old code. */
zval tmp;
ZVAL_NEW_STR(&tmp, zend_string_init("0", sizeof("0") - 1, 1));
zend_hash_str_update(configuration_hash, "opcache.revalidate_freq",
sizeof("opcache.revalidate_freq") - 1, &tmp);
}
}

Comment on lines +1525 to +1535

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.

I'm not a fan of this. Can we not just do this for tests?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I'm not sure to follow. It's not to fix our tests but making hot reload reliable.

@henderkes henderkes Oct 2, 2026 •

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.

Oh, I thought this was only needed for tests. Have you benchmarked the impacts of this? Especially on Windows this could be a severe performance degradation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It's only in dev, and looks like the only way to prevent executing stale code when using hot reload.

sapi_module_struct frankenphp_sapi_module = {
"frankenphp", /* name */
"FrankenPHP", /* pretty name */
Expand Down Expand Up @@ -1815,6 +1826,8 @@ static void *php_main(void *arg) {
frankenphp_sapi_module.ini_entries = php_ini_overrides;
}

frankenphp_sapi_module.ini_defaults = frankenphp_ini_defaults;

frankenphp_init_interned_strings();

/* take a snapshot of the environment for sandboxing */
Expand Down
5 changes: 5 additions & 0 deletions frankenphp.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ var (
// atomic: read by in-flight requests while a reload may rewrite it
maxWaitTime atomic.Int64
maxRequestsPerThread int

hotReloadEnabled bool
)

type ErrRejected struct {
Expand Down Expand Up @@ -427,6 +429,8 @@ func Init(options ...Option) error {
}
}

hotReloadEnabled = opt.hasHotReload()

mainThread, err := initPHPThreads(opt.numThreads, opt.maxThreads, opt.phpIni)
if err != nil {
shutdown()
Expand Down Expand Up @@ -962,6 +966,7 @@ func resetGlobals() {
globalWorkersByPath = nil
servers = nil
watcherIsEnabled = false
hotReloadEnabled = false
maxIdleTime = defaultMaxIdleTime
maxRequestsPerThread = 0
}
47 changes: 47 additions & 0 deletions hotreload_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//go:build !nomercure && !nowatcher

package frankenphp_test

import (
"net/http"
"net/http/httptest"
"strings"
"testing"

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

func TestHotReloadOpcacheRevalidateFreq(t *testing.T) {
for name, tc := range map[string]struct {
hotReload bool
phpIni map[string]string
expected string
}{
"hot reload": {hotReload: true, expected: "0"},
"hot reload with php_ini": {hotReload: true, phpIni: map[string]string{"opcache.revalidate_freq": "5"}, expected: "5"},
"without hot reload": {expected: "2"},
"without hot reload php_ini": {phpIni: map[string]string{"opcache.revalidate_freq": "5"}, expected: "5"},
} {
t.Run(name, func(t *testing.T) {
opts := &testOptions{nbParallelRequests: 1, phpIni: tc.phpIni}
if tc.hotReload {
h, err := mercure.NewHub(t.Context(), mercure.WithTransport(mercure.NewLocalTransport(mercure.NewSubscriberList(0))))
require.NoError(t, err)

opts.initOpts = []frankenphp.Option{frankenphp.WithHotReload("hot-reload", h, []string{"./testdata/**/*.none"})}
}

runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) {
body, _ := testGet("http://example.com/ini.php?key=opcache.revalidate_freq", handler, t)
if strings.HasSuffix(body, ":") {
t.Skip("OPcache is not loaded")
}

assert.Equal(t, "opcache.revalidate_freq:"+tc.expected, body)
}, opts)
})
}
}
5 changes: 5 additions & 0 deletions phpmainthread.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,11 @@ func go_frankenphp_shutdown_main_thread() {
mainThread.state.Set(state.Reserved)
}

//export go_is_hot_reload_enabled
func go_is_hot_reload_enabled() C.bool {
return C.bool(hotReloadEnabled)
}

//export go_get_custom_php_ini
func go_get_custom_php_ini(disableTimeouts C.bool) *C.char {
if mainThread.phpIni == nil {
Expand Down
4 changes: 4 additions & 0 deletions watcher-skip.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import "errors"
type hotReloadOpt struct {
}

func (*hotReloadOpt) hasHotReload() bool {
return false
}

var errWatcherNotEnabled = errors.New("watcher support is not enabled")

// validateWatchers reports what initWatchers() would refuse, so Validate()
Expand Down
4 changes: 4 additions & 0 deletions watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ func validateWatchers(*opt) error {
return nil
}

func (o *hotReloadOpt) hasHotReload() bool {
return len(o.hotReload) != 0
}

func initWatchers(o *opt) error {
watchPatterns := make([]*watcher.PatternGroup, 0, len(o.hotReload))

Expand Down
Loading