Skip to content

Working toward Octane support: make core request state worker-safe - #1511

Open
austinderrick wants to merge 6 commits into
wintercms:wip/1.3from
austinderrick:feat/octane-worker-support
Open

Working toward Octane support: make core request state worker-safe#1511
austinderrick wants to merge 6 commits into
wintercms:wip/1.3from
austinderrick:feat/octane-worker-support

Conversation

@austinderrick

@austinderrick austinderrick commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

DRAFT UNTIL wintercms/storm#241 is merged!

Summary

Working toward Octane support. Requires wintercms/storm#241 for the primitives it calls.

Under PHP-FPM every request gets a fresh process, so state left on a long-lived object never hurts anyone. Under Laravel Octane the same process serves many requests, and anything derived from one request stays visible to the next. Most of what follows was found by running a real application on a worker and watching it misbehave, not by reading the source. Several of these leak one user's data to another.

None of this changes PHP-FPM behavior. What remains in this PR is inert without a worker: it makes core's request-derived state resettable, and nothing in core resets it.

Split with the Winter.Octane plugin

Following the suggestion in review, the Octane-specific integration now lives in a first-party plugin: Winter.Octane. The plugin registers Octane's service provider (Winter disables Laravel's package discovery, so an installed laravel/octane otherwise does nothing), clears request state at the start of every operation ahead of Octane's own listeners, calls resetWorkerState() on any plugin that defines it, and carries the worker test suite, which drives Octane's real ApplicationGateway against a single application instance. Its composer.json requires laravel/octane, so this repo no longer suggests the package.

Two consequences of plugin placement are worth weighing in review:

  • The plugin registers as elevated, so it still loads on the privileged routes and commands where PluginManager::$noInit skips normal plugin initialization. Without that, the first request a worker served could decide that the reset never attaches for the worker's lifetime.
  • A disabled plugin never registers. Disabling Winter.Octane while workers are still serving would silently stop the reset, so the plugin's README tells operators to switch back to PHP-FPM before disabling it. This is the one safety property that core wiring would have provided and a plugin cannot.

What stays in this PR are the changes a plugin cannot make: core classes exposing their request-derived state in resettable form, and boot-time decisions that must admit a worker.

Manager state rebuilt per request

Five managers build their contents once and cache the result. The current user's permissions filter some of that content. A copy built for one back-end user then went to whoever came next.

  • Backend\Classes\NavigationManager caches the main menu and side menus after permission filtering.
  • Backend\Classes\WidgetManager caches the report widget list.
  • System\Classes\SettingsManager caches settings items after filtering.
  • System\Classes\MailManager caches registered mail partials and layouts.
  • Cms\Classes\ComponentManager caches resolved component details.

Each now separates what is registered once per worker from what is derived per request, and rebuilds only the second part. Registrations survive, which matters because rebuilding them on every request would cost more than the leak.

Trait declared caches

Backend\Traits\PreferenceMaker and the URL maker keep per-request caches in static properties declared on the trait rather than on the classes that use it.

PHP gives every using class its own copy of a trait's statics, so assigning through the trait clears nothing. The reset resolves the classes that use each trait and clears the property on each one. The preference cache is also keyed by user, since the same key previously served whichever user asked first.

Boot time gates

Backend, System and Cms service providers registered parts of themselves only when runningInBackend() was true. Winter evaluates that test once per worker, at boot, when no request exists. Back-end registration therefore never ran at all under Octane.

The gates now also admit an application server, using the detection method added in wintercms/storm#241.

CMS code cache

Cms\Classes\CodeParser::rebuild() wrote a generated class to a deterministic path and loaded it with require_once. The class name inside is random for every rebuild, and require_once keys on the path.

In a process serving more than one request, the path was normally included already. The load then did nothing at all and left the new class undeclared:

Error: Class "Cms<hash>Class" not found in CodeParser.php

This also disabled the recovery path in handleCorruptCache(), whose whole purpose is to rebuild after finding the cache unusable. It rebuilt, then could not load what it had rebuilt. Using require cannot redeclare anything, because the class name is unique to each rebuild.

Twig include gate

System\Twig\Engine opened a gate before loading a template and closed it afterward. A throw during loading skipped the close, so the gate stayed open for every later request the worker served.

The close now happens in a finally block.

Testing

Remaining in this PR:

File Covers
modules/cms/tests/classes/CodeParserWorkerTest.php A rebuilt cache file loads in a reused process
modules/system/tests/twig/EngineTest.php The include gate closes after a throw

The worker tests moved to Winter.Octane, where they exercise the plugin's own wiring rather than core's: request state isolation, listener ordering and priority, cross-user manager state, the reset manifest, and plugin reset discovery, all dispatched through Octane's real gateway.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: daf88cfb-dd0f-4982-a26f-69da9e838916

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@austinderrick
austinderrick marked this pull request as draft August 12, 2026 00:54
@austinderrick
austinderrick force-pushed the feat/octane-worker-support branch 3 times, most recently from edbff29 to 468913a Compare August 12, 2026 01:16
…oundary

Under PHP-FPM every request gets a fresh process, so state left on a long-lived object never hurts
anyone. Under Laravel Octane the same process serves many requests, and anything derived from one
request stays visible to the next. Several of these leak one user's data to another.

Nothing here requires the companion Storm changes to be present. Winter has to load on a Storm that
predates them, and `implements` is resolved when a class is loaded, so naming a contract that a given
Storm may not ship makes a class unloadable rather than merely unresettable. The managers therefore
declare resetWorkerState() without naming ResetsWorkerState, and the boot-time gates reach
runningInApplicationServer() through method_exists(). Where the Storm side is absent, the gates answer
exactly as they do today, which is correct: there is no worker support to admit.

Winter also takes no dependency on laravel/octane, not even for development. The package appears in
suggest only, since a persistent worker is something an application opts into. Every Octane test skips
where the package is absent, and the test bootstrap builds its no-op client as an anonymous class so
that no file naming an Octane contract has to load when there is nothing to name.

Registers Octane's service provider when the package is installed, since Winter replaces Laravel's
provider discovery and the provider was never picked up. Adds System\Classes\Octane\ResetsRequestState,
which clears request-derived state at the start of every operation rather than the end, because an
exception escaping the HTTP kernel skips terminate() and RequestTerminated never fires.

Rebuilds the permission-filtered contents of NavigationManager, WidgetManager, SettingsManager,
MailManager and ComponentManager per request, while leaving their once-per-worker registrations alone.
Clears the per-request caches that PreferenceMaker and the URL maker declare on traits, which needs the
using classes resolved because PHP gives each of them its own copy. Keys the preference cache by user.

Admits an application server in the boot-time gates of the Backend, System and Cms providers. Those
gates test runningInBackend() once per worker, at boot, when no request exists, so back-end registration
never ran at all under a worker.

Loads a rebuilt CMS code cache file with require rather than require_once. The path is deterministic
while the class name inside is random per rebuild, so in a reused process the load did nothing and left
the class undeclared, which also disabled the recovery path in handleCorruptCache().

Closes the Twig include gate in a finally block, so a throw during template loading no longer leaves it
open for every later request.

Invokes the plugin reset on any plugin that implements ResetsWorkerState or simply declares
resetWorkerState(), for the same loading reason as the managers above.

Tests: 499 green against the companion Storm branch, and 499 green against stock wip/1.3 Storm with
laravel/octane absent, where the Octane tests skip. Each fix was verified failing before its change.
Also exercised end to end against a Winter based application of about thirty plugins running on Octane
and FrankenPHP, whose Cypress suite of 36 groups passes on Octane and PHP-FPM alike.
@austinderrick
austinderrick force-pushed the feat/octane-worker-support branch from 468913a to 1be38b5 Compare August 12, 2026 01:23
@LukeTowers

Copy link
Copy Markdown
Member

@austinderrick can Octane support be provided by a first party plugin (i.e. Winter.Octane)?

@austinderrick

Copy link
Copy Markdown
Contributor Author

@austinderrick can Octane support be provided by a first party plugin (i.e. Winter.Octane)?

Hey @LukeTowers : we could move some of this to a first party plugin, like the registerOctane/resetRequestState orchestrator, etc.

A big chunk of the changes here are more specific to the Winter internals and how they hold onto variables, so we'll probably need to keep some of them. (And the partner wintercms/storm#241 too).

I'll draft something up for it!

The worker-safety primitives stay in core: resettable manager state, the
ResetsWorkerState contract in Storm, and the boot-vs-request gating fixes.
What moves to the plugin is the Octane-specific wiring — registering
Laravel\Octane\OctaneServiceProvider (Winter disables package discovery),
the request-boundary ResetsRequestState listener, and the tests that drive
Octane's ApplicationGateway.

The plugin declares itself elevated so the reset still attaches when
PluginManager::$noInit skips ordinary plugin initialization. The composer
suggestion now points at winter/wn-octane-plugin, which requires
laravel/octane itself.
@austinderrick austinderrick changed the title Working toward Octane support: reset request state at the operation boundary Working toward Octane support: make core request state worker-safe Aug 13, 2026
The plugin's own composer.json requires laravel/octane; core does not need
to advertise it.
@austinderrick

austinderrick commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

@LukeTowers : I made a repo over here for the plugin and invited you, so we can move it to the Winter org: https://github.com/austinderrick/Winter.Octane

Also on this PR and the related Storm one, I rewired them to work with the plugin instead.
(Both need some changes to fix state operations, but it's much more minimal with the plugin. I like this approach a lot more!)

@austinderrick
austinderrick marked this pull request as ready for review August 13, 2026 23:48
@LukeTowers

Copy link
Copy Markdown
Member

@austinderrick if you make me an admin or an owner on the repo then I can transfer it to the Winter organization and you can continue working on it from the official namespace.

@austinderrick

Copy link
Copy Markdown
Contributor Author

@austinderrick if you make me an admin or an owner on the repo then I can transfer it to the Winter organization and you can continue working on it from the official namespace.

@LukeTowers : Yep! Transfer headed your way.

@LukeTowers

Copy link
Copy Markdown
Member

@austinderrick repository has been transferred and added to packagist: https://packagist.org/packages/winter/wn-octane-plugin

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.

2 participants