diff --git a/.gitignore b/.gitignore index 7a47d39bab..f30ac80d98 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,7 @@ devhub/pm-font/dist test-db-snapshot.db snapshot_*.db storage/transitions -.envrc \ No newline at end of file +.envrc +**/caddy +frankenphp +frankenphp-worker.php diff --git a/ProcessMaker/Http/Middleware/ServerTimingMiddleware.php b/ProcessMaker/Http/Middleware/ServerTimingMiddleware.php index 96ade2bded..2a15e84a6d 100644 --- a/ProcessMaker/Http/Middleware/ServerTimingMiddleware.php +++ b/ProcessMaker/Http/Middleware/ServerTimingMiddleware.php @@ -28,6 +28,8 @@ public function handle(Request $request, Closure $next): Response return $next($request); } + ProcessMakerServiceProvider::beginRequestTiming(); + // Start time for controller execution $startController = microtime(true); diff --git a/ProcessMaker/Listeners/HandleRedirectListener.php b/ProcessMaker/Listeners/HandleRedirectListener.php index 78491809a4..7679a73572 100644 --- a/ProcessMaker/Listeners/HandleRedirectListener.php +++ b/ProcessMaker/Listeners/HandleRedirectListener.php @@ -20,6 +20,17 @@ protected function setRedirectTo(ProcessRequest $processRequest, string $method, self::$redirectionParams = $params; } + /** + * Reset the static state for Octane compatibility. + * This prevents data leaks between requests in long-running workers. + */ + public static function reset(): void + { + self::$processRequest = null; + self::$redirectionMethod = ''; + self::$redirectionParams = []; + } + public static function sendRedirectToEvent() { $method = self::$redirectionMethod; diff --git a/ProcessMaker/Octane/ResetRequestState.php b/ProcessMaker/Octane/ResetRequestState.php new file mode 100644 index 0000000000..45071e97ad --- /dev/null +++ b/ProcessMaker/Octane/ResetRequestState.php @@ -0,0 +1,17 @@ +checkConfigCache(); + // Register Octane listeners if Octane is enabled + $this->registerOctaneListeners(); + // Hook after service providers boot self::$bootTime = (microtime(true) - self::$bootStart) * 1000; // Convert to milliseconds } @@ -260,7 +266,7 @@ protected static function registerEvents(): void { // Listen to the events for our core screen // types and add our javascript - Facades\Event::listen(ScreenBuilderStarting::class, function ($event) { + Event::listen(ScreenBuilderStarting::class, function ($event) { // Add any extensions to form builder // and renderer from packages $event->manager->addPackageScripts($event->type); @@ -279,7 +285,7 @@ protected static function registerEvents(): void }); // Log Notifications - Facades\Event::listen(NotificationSent::class, function ($event) { + Event::listen(NotificationSent::class, function ($event) { $id = $event->notifiable->id; $notifiable = get_class($event->notifiable); $notification = get_class($event->notification); @@ -288,24 +294,24 @@ protected static function registerEvents(): void }); // Log Broadcasts (messages sent to laravel-echo-server and redis) - Facades\Event::listen(BroadcastNotificationCreated::class, function ($event) { + Event::listen(BroadcastNotificationCreated::class, function ($event) { $channels = implode(', ', $event->broadcastOn()); Log::debug('Broadcasting Notification ' . $event->broadcastType() . 'on channel(s) ' . $channels); }); // Fire job when task is assigned to a user - Facades\Event::listen(ActivityAssigned::class, function ($event) { + Event::listen(ActivityAssigned::class, function ($event) { $task_id = $event->getProcessRequestToken()->id; // Dispatch the SmartInbox job with the processRequestToken as parameter SmartInbox::dispatch($task_id); }); - Facades\Event::listen(MadeTenantCurrentEvent::class, function ($event) { + Event::listen(MadeTenantCurrentEvent::class, function ($event) { event(new TenantResolved($event->tenant)); }); - Facades\Event::listen(TenantNotFoundForRequestEvent::class, function ($event) { + Event::listen(TenantNotFoundForRequestEvent::class, function ($event) { if (config('app.multitenancy') === false || self::actuallyRunningInConsole()) { // This is expected if multitenancy is disabled. // We also need to check if we are running in a console command because @@ -330,7 +336,7 @@ protected static function registerEvents(): void } }); - Facades\Event::listen(function (CommandStarting $event) { + Event::listen(function (CommandStarting $event) { if ($event->command === 'l5-swagger:generate') { // Set the analyser to use the legacy DocBlockAnnotationFactory. This must // be set here because this config value is not serializable and cannot be cached. @@ -511,6 +517,14 @@ public static function getBootTime(): ?float return self::$bootTime; } + /** + * Reset per-request query timing metrics. + */ + public static function beginRequestTiming(): void + { + self::$queryTime = 0; + } + /** * Get the query time for the request. * @@ -568,6 +582,23 @@ public static function getPackageBootTiming(): array return self::$packageBootTiming; } + /** + * Reset per-request static state between Octane requests. + * + * Octane workers stay alive across requests, so static properties must be + * cleared to avoid leaking data from one request into the next. Singletons + * holding mutable state are handled by the 'flush' list in config/octane.php, + * which Octane applies on its own. + */ + private function registerOctaneListeners(): void + { + if (!class_exists(RequestTerminated::class)) { + return; + } + + Event::listen(RequestTerminated::class, ResetRequestState::class); + } + /** * Find the tenant based on the environment variable */ diff --git a/ProcessMaker/Repositories/SettingsConfigRepository.php b/ProcessMaker/Repositories/SettingsConfigRepository.php index 2f4cf6e0b2..f2707faf2f 100644 --- a/ProcessMaker/Repositories/SettingsConfigRepository.php +++ b/ProcessMaker/Repositories/SettingsConfigRepository.php @@ -39,7 +39,7 @@ public function get($key, $default = null) if ($key === 'session.lifetime') { $settingValue = $this->getFromSettings($key); - return $settingValue ?? $default; + return $settingValue ?: Arr::get($this->items, $key) ?: $default ?: 120; } if (Arr::has($this->items, $key)) { diff --git a/composer.json b/composer.json index 1bd4939f7f..474148e089 100644 --- a/composer.json +++ b/composer.json @@ -25,8 +25,9 @@ "guzzlehttp/psr7": "^2.12.3", "igaster/laravel-theme": "^2.0", "jenssegers/agent": "^2.6", - "laravel/framework": "^13.13", - "laravel/horizon": "^5.47", + "laravel/framework": "^13.0", + "laravel/horizon": "^5.45", + "laravel/octane": "^2.17", "laravel/pail": "^1.2", "laravel/passport": "^13.7", "laravel/scout": "^11.1", @@ -253,4 +254,4 @@ "ignore": [] } } -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index 7424e10bfa..b507253c75 100644 --- a/composer.lock +++ b/composer.lock @@ -2896,6 +2896,94 @@ ], "time": "2020-06-13T08:05:20+00:00" }, + { + "name": "laminas/laminas-diactoros", + "version": "3.8.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-diactoros.git", + "reference": "60c182916b2749480895601649563970f3f12ec4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/60c182916b2749480895601649563970f3f12ec4", + "reference": "60c182916b2749480895601649563970f3f12ec4", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "conflict": { + "amphp/amp": "<2.6.4" + }, + "provide": { + "psr/http-factory-implementation": "^1.0", + "psr/http-message-implementation": "^1.1 || ^2.0" + }, + "require-dev": { + "ext-curl": "*", + "ext-dom": "*", + "ext-gd": "*", + "ext-libxml": "*", + "http-interop/http-factory-tests": "^2.2.0", + "laminas/laminas-coding-standard": "~3.1.0", + "php-http/psr7-integration-tests": "^1.4.0", + "phpunit/phpunit": "^10.5.36", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13" + }, + "type": "library", + "extra": { + "laminas": { + "module": "Laminas\\Diactoros", + "config-provider": "Laminas\\Diactoros\\ConfigProvider" + } + }, + "autoload": { + "files": [ + "src/functions/create_uploaded_file.php", + "src/functions/marshal_headers_from_sapi.php", + "src/functions/marshal_method_from_sapi.php", + "src/functions/marshal_protocol_version_from_sapi.php", + "src/functions/normalize_server.php", + "src/functions/normalize_uploaded_files.php", + "src/functions/parse_cookie_header.php" + ], + "psr-4": { + "Laminas\\Diactoros\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "PSR HTTP Message implementations", + "homepage": "https://laminas.dev", + "keywords": [ + "http", + "laminas", + "psr", + "psr-17", + "psr-7" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-diactoros/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-diactoros/issues", + "rss": "https://github.com/laminas/laminas-diactoros/releases.atom", + "source": "https://github.com/laminas/laminas-diactoros" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-10-12T15:31:36+00:00" + }, { "name": "laravel/framework", "version": "v13.13.0", @@ -3200,6 +3288,95 @@ }, "time": "2026-06-03T15:11:37+00:00" }, + { + "name": "laravel/octane", + "version": "v2.17.5", + "source": { + "type": "git", + "url": "https://github.com/laravel/octane.git", + "reference": "058ae4d7109eed40836dc42960f9388b9bf71f73" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/octane/zipball/058ae4d7109eed40836dc42960f9388b9bf71f73", + "reference": "058ae4d7109eed40836dc42960f9388b9bf71f73", + "shasum": "" + }, + "require": { + "laminas/laminas-diactoros": "^3.0", + "laravel/framework": "^10.10.1|^11.0|^12.0|^13.0", + "laravel/prompts": "^0.1.24|^0.2.0|^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "nesbot/carbon": "^2.66.0|^3.0", + "php": "^8.1.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/psr-http-message-bridge": "^2.2.0|^6.4|^7.0|^8.0" + }, + "conflict": { + "spiral/roadrunner": "<2023.1.0", + "spiral/roadrunner-cli": "<2.6.0", + "spiral/roadrunner-http": "<3.3.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.6.1", + "inertiajs/inertia-laravel": "^1.3.2|^2.0", + "laravel/scout": "^10.2.1", + "laravel/socialite": "^5.6.1", + "livewire/livewire": "^2.12.3|^3.0", + "nunomaduro/collision": "^6.4.0|^7.5.2|^8.0", + "orchestra/testbench": "^8.21|^9.0|^10.0|^11.0", + "phpstan/phpstan": "^2.1.7", + "phpunit/phpunit": "^10.4|^11.5|^12.0|^13.0", + "spiral/roadrunner-cli": "^2.6.0", + "spiral/roadrunner-http": "^3.3.0" + }, + "bin": [ + "bin/roadrunner-worker", + "bin/swoole-server" + ], + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Octane": "Laravel\\Octane\\Facades\\Octane" + }, + "providers": [ + "Laravel\\Octane\\OctaneServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Octane\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Supercharge your Laravel application's performance.", + "keywords": [ + "frankenphp", + "laravel", + "octane", + "roadrunner", + "swoole" + ], + "support": { + "issues": "https://github.com/laravel/octane/issues", + "source": "https://github.com/laravel/octane" + }, + "time": "2026-06-04T09:05:08+00:00" + }, { "name": "laravel/pail", "version": "v1.2.6", diff --git a/config/octane.php b/config/octane.php new file mode 100644 index 0000000000..598df4b7c6 --- /dev/null +++ b/config/octane.php @@ -0,0 +1,232 @@ + env('OCTANE_SERVER', 'roadrunner'), + + /* + |-------------------------------------------------------------------------- + | Force HTTPS + |-------------------------------------------------------------------------- + | + | When this configuration value is set to "true", Octane will inform the + | framework that all absolute links must be generated using the HTTPS + | protocol. Otherwise your links may be generated using plain HTTP. + | + */ + + 'https' => env('OCTANE_HTTPS', false), + + /* + |-------------------------------------------------------------------------- + | Octane Listeners + |-------------------------------------------------------------------------- + | + | All of the event listeners for Octane's events are defined below. These + | listeners are responsible for resetting your application's state for + | the next request. You may even add your own listeners to the list. + | + */ + + 'listeners' => [ + WorkerStarting::class => [ + EnsureUploadedFilesAreValid::class, + EnsureUploadedFilesCanBeMoved::class, + ], + + RequestReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + ...Octane::prepareApplicationForNextRequest(), + // + ], + + RequestHandled::class => [ + // + ], + + RequestTerminated::class => [ + // FlushUploadedFiles::class, + ], + + TaskReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + // + ], + + TaskTerminated::class => [ + // + ], + + TickReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + // + ], + + TickTerminated::class => [ + // + ], + + OperationTerminated::class => [ + FlushOnce::class, + FlushTemporaryContainerInstances::class, + // DisconnectFromDatabases::class, + // CollectGarbage::class, + ], + + WorkerErrorOccurred::class => [ + ReportException::class, + StopWorkerIfNecessary::class, + ], + + WorkerStopping::class => [ + CloseMonologHandlers::class, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Warm / Flush Bindings + |-------------------------------------------------------------------------- + | + | The bindings listed below will either be pre-warmed when a worker boots + | or they will be flushed before every new request. Flushing a binding + | will force the container to resolve that binding again when asked. + | + */ + + 'warm' => [ + ...Octane::defaultServicesToWarm(), + // Services to pre-resolve on worker start + ProcessMaker\Managers\PackageManager::class, + ProcessMaker\Managers\LoginManager::class, + ProcessMaker\Managers\IndexManager::class, + ], + + 'flush' => [ + // Services with mutable state that must be recreated per request + ProcessMaker\Models\AnonymousUser::class, + ProcessMaker\ImportExport\Extension::class, + ProcessMaker\ImportExport\SignalHelper::class, + ProcessMaker\Managers\MenuManager::class, + ], + + /* + |-------------------------------------------------------------------------- + | Octane Swoole Tables + |-------------------------------------------------------------------------- + | + | While using Swoole, you may define additional tables as required by the + | application. These tables can be used to store data that needs to be + | quickly accessed by other workers on the particular Swoole server. + | + */ + + 'tables' => [ + 'example:1000' => [ + 'name' => 'string:1000', + 'votes' => 'int', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Octane Swoole Cache Table + |-------------------------------------------------------------------------- + | + | While using Swoole, you may leverage the Octane cache, which is powered + | by a Swoole table. You may set the maximum number of rows as well as + | the number of bytes per row using the configuration options below. + | + */ + + 'cache' => [ + 'rows' => 1000, + 'bytes' => 10000, + ], + + /* + |-------------------------------------------------------------------------- + | File Watching + |-------------------------------------------------------------------------- + | + | The following list of files and directories will be watched when using + | the --watch option offered by Octane. If any of the directories and + | files are changed, Octane will automatically reload your workers. + | + */ + + 'watch' => [ + 'app', + 'bootstrap', + 'config/**/*.php', + 'database/**/*.php', + 'public/**/*.php', + 'resources/**/*.php', + 'routes', + 'composer.lock', + '.env', + ], + + /* + |-------------------------------------------------------------------------- + | Garbage Collection Threshold + |-------------------------------------------------------------------------- + | + | When executing long-lived PHP scripts such as Octane, memory can build + | up before being cleared by PHP. You can force Octane to run garbage + | collection if your application consumes this amount of megabytes. + | + */ + + 'garbage' => 50, + + /* + |-------------------------------------------------------------------------- + | Maximum Execution Time + |-------------------------------------------------------------------------- + | + | The following setting configures the maximum execution time for requests + | being handled by Octane. You may set this value to 0 to indicate that + | there isn't a specific time limit on Octane request execution time. + | + */ + + 'max_execution_time' => 30, + +]; diff --git a/tests/Feature/ServerTimingMiddlewareTest.php b/tests/Feature/ServerTimingMiddlewareTest.php index b9449ebfed..040401d227 100644 --- a/tests/Feature/ServerTimingMiddlewareTest.php +++ b/tests/Feature/ServerTimingMiddlewareTest.php @@ -6,6 +6,8 @@ use Illuminate\Support\Facades\Route; use ProcessMaker\Http\Middleware\ServerTimingMiddleware; use ProcessMaker\Models\User; +use ProcessMaker\Providers\ProcessMakerServiceProvider; +use ReflectionClass; use Tests\Feature\Shared\RequestHelper; use Tests\TestCase; @@ -41,6 +43,28 @@ public function testServerTimingHeaderIncludesAllMetrics() $this->assertStringContainsString('db;dur=', $serverTiming[2]); } + public function testBeginRequestTimingClearsAccumulatedQueryTime() + { + $reflection = new ReflectionClass(ProcessMakerServiceProvider::class); + $property = $reflection->getProperty('queryTime'); + $property->setAccessible(true); + $property->setValue(null, 500); + + Route::middleware(ServerTimingMiddleware::class)->get('/timing-reset-test', function () { + DB::select('SELECT 1'); + + return response()->json(['message' => 'Timing reset test']); + }); + + $response = $this->get('/timing-reset-test'); + $serverTiming = $this->getHeader($response, 'server-timing'); + + preg_match('/db;dur=([\d.]+)/', implode(',', $serverTiming), $matches); + $dbTime = (float) ($matches[1] ?? 500); + + $this->assertLessThan(500, $dbTime); + } + public function testQueryTimeIsMeasured() { // Mock a route with a query diff --git a/tests/unit/ProcessMaker/Octane/ResetRequestStateTest.php b/tests/unit/ProcessMaker/Octane/ResetRequestStateTest.php new file mode 100644 index 0000000000..7895d8519f --- /dev/null +++ b/tests/unit/ProcessMaker/Octane/ResetRequestStateTest.php @@ -0,0 +1,74 @@ +assertGreaterThan(0, ProcessMakerServiceProvider::getQueryTime()); + + $listener = new ResetRequestState(); + $listener->handle(); + + $this->assertSame(0.0, ProcessMakerServiceProvider::getQueryTime()); + } + + public function test_it_prevents_redirect_state_from_leaking_into_the_next_request(): void + { + Event::fake([RedirectToEvent::class]); + + $redirectListener = new RedirectStateProbe(); + $redirectListener->queue(ProcessRequest::factory()->create()); + + $listener = new ResetRequestState(); + $listener->handle(); + + HandleRedirectListener::sendRedirectToEvent(); + + Event::assertNotDispatched(RedirectToEvent::class); + } + + public function test_octane_request_termination_automatically_resets_request_state(): void + { + Event::fake([RedirectToEvent::class]); + + $redirectListener = new RedirectStateProbe(); + $redirectListener->queue(ProcessRequest::factory()->create()); + + event(new RequestTerminated( + $this->app, + $this->app, + Request::create('/first-request'), + new Response() + )); + + HandleRedirectListener::sendRedirectToEvent(); + + Event::assertNotDispatched(RedirectToEvent::class); + } +} + +final class RedirectStateProbe extends HandleRedirectListener +{ + public function queue(ProcessRequest $processRequest): void + { + $this->setRedirectTo($processRequest, 'processUpdated'); + } +}