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/Nayra/Repositories/EntityRepository.php b/ProcessMaker/Nayra/Repositories/EntityRepository.php index 4b10af1a02..2f84e55b7e 100644 --- a/ProcessMaker/Nayra/Repositories/EntityRepository.php +++ b/ProcessMaker/Nayra/Repositories/EntityRepository.php @@ -11,7 +11,7 @@ abstract class EntityRepository { - private static $uid2id = ['requests' =>[], 'tokens' =>[]]; + private $uid2id = ['requests' =>[], 'tokens' =>[]]; abstract public function create(array $transaction): ? Model; @@ -41,16 +41,16 @@ public function resolveId(string $uid): int } // Get record if is not stored previously - if (!isset(self::$uid2id[$type][$uid])) { + if (!isset($this->uid2id[$type][$uid])) { $record = $instance->select('id')->where('uuid', $uid)->first(); if ($record) { - self::$uid2id[$type][$uid] = $record->getKey(); + $this->uid2id[$type][$uid] = $record->getKey(); } else { throw new Exception("The uid {$uid} does not exist in the database"); } } - return self::$uid2id[$type][$uid] ?? 0; + return $this->uid2id[$type][$uid] ?? 0; } /** @@ -71,6 +71,6 @@ public function storeUid(string $uid, int $id): void break; } - self::$uid2id[$type][$uid] = $id; + $this->uid2id[$type][$uid] = $id; } } 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 } @@ -257,7 +263,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); @@ -276,7 +282,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); @@ -285,24 +291,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 @@ -327,7 +333,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. @@ -573,6 +579,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/config/octane.php b/config/octane.php new file mode 100644 index 0000000000..5a9a9c642a --- /dev/null +++ b/config/octane.php @@ -0,0 +1,34 @@ + [ + // 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, + ], + + 'warm' => [ + ...Laravel\Octane\Octane::defaultServicesToWarm(), + + // Services to pre-resolve on worker start + ProcessMaker\Managers\PackageManager::class, + ProcessMaker\Managers\LoginManager::class, + ProcessMaker\Managers\IndexManager::class, + ], +]; diff --git a/tests/unit/ProcessMaker/Nayra/Repositories/EntityRepositoryTest.php b/tests/unit/ProcessMaker/Nayra/Repositories/EntityRepositoryTest.php new file mode 100644 index 0000000000..29986e2f77 --- /dev/null +++ b/tests/unit/ProcessMaker/Nayra/Repositories/EntityRepositoryTest.php @@ -0,0 +1,66 @@ +assertFalse( + $reflection->isStatic(), + 'uid2id must NOT be static to prevent data leaks between requests in Octane' + ); + } + + /** + * Test that $uid2id is a private property. + */ + public function test_uid2id_is_private(): void + { + $reflection = new ReflectionProperty(EntityRepository::class, 'uid2id'); + + $this->assertTrue( + $reflection->isPrivate(), + 'uid2id should be private' + ); + } +} 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'); + } +}