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
19 changes: 17 additions & 2 deletions frankenphp.go
Original file line number Diff line number Diff line change
Expand Up @@ -784,9 +784,24 @@ func go_is_context_done(threadIndex C.uintptr_t) C.bool {

//export go_schedule_opcache_reset
func go_schedule_opcache_reset(threadIndex C.uintptr_t) {
if mainThread != nil {
go mainThread.rebootAllThreads()
if mainThread == nil {
return
}

// Application code (e.g. some WordPress plugins) can call opcache_reset()
// on every request. Since a reboot pauses request handling for every
// thread, coalesce calls that arrive within the cooldown into a single
// reboot instead of pausing the whole pool on every call.
mainThread.opcacheResetMu.Lock()
now := time.Now()
if now.Sub(mainThread.lastOpcacheResetAt) < opcacheResetCooldown {
mainThread.opcacheResetMu.Unlock()
return
}
mainThread.lastOpcacheResetAt = now
mainThread.opcacheResetMu.Unlock()

go mainThread.rebootAllThreads()
}

func convertArgs(args []string) (C.int, []*C.char) {
Expand Down
29 changes: 29 additions & 0 deletions frankenphp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,35 @@ func testFinishRequest(t *testing.T, opts *testOptions) {
}, opts)
}

// TestOpcacheResetIsThrottled reproduces https://github.com/php/frankenphp/issues/2553:
// application code (e.g. some WordPress plugins) can call opcache_reset() on
// every request, and each call reboots the whole PHP thread pool. Without a
// cooldown, a burst of concurrent calls reboots the pool once per call,
// pausing request handling for the whole site on every one of them.
func TestOpcacheResetIsThrottled(t *testing.T) {
var buf fmt.Stringer
opts := &testOptions{nbParallelRequests: 1}
opts.logger, buf = newTestLogger(t)

runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) {
// Calls spaced out enough that, without a cooldown, each one lands
// after the previous reboot already finished (so each would trigger
// its own reboot), but still well within the cooldown window.
for n := 0; n < 10; n++ {
body, _ := testGet("http://example.com/opcache_reset.php", handler, t)
assert.Equal(t, "opcache reset done", body)
time.Sleep(50 * time.Millisecond)
}
}, opts)

require.Eventually(t, func() bool {
return strings.Contains(buf.String(), "thread reboot finished")
}, 2*time.Second, 10*time.Millisecond, "the throttled reboot should still eventually run")

count := strings.Count(buf.String(), "rebooting all PHP threads")
assert.Equal(t, 1, count, "opcache_reset() calls within the cooldown must be coalesced into a single reboot")
}

func TestServerVariable_module(t *testing.T) {
testServerVariable(t, nil)
}
Expand Down
9 changes: 9 additions & 0 deletions phpmainthread.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ type phpMainThread struct {
maxThreads int
phpIni map[string]string
isRebooting atomic.Bool

// throttles opcache_reset()-triggered reboots (see go_schedule_opcache_reset):
// application code can call opcache_reset() on every request, and each call
// would otherwise pause request handling for a full thread pool reboot
opcacheResetMu sync.Mutex
lastOpcacheResetAt time.Time
}

var (
Expand All @@ -36,6 +42,9 @@ var (
// timeouts to wait for threads to yield before arming force-kill
shutDownGracePeriod = 30 * time.Second
rebootGracePeriod = 6 * time.Second

// minimum interval between two opcache_reset()-triggered reboots
opcacheResetCooldown = 5 * time.Second
)

// initPHPThreads starts the main PHP thread,
Expand Down
Loading