diff --git a/src/Listeners/PrepareLivewireForNextOperation.php b/src/Listeners/PrepareLivewireForNextOperation.php index 4cf7ec1..07d96ca 100644 --- a/src/Listeners/PrepareLivewireForNextOperation.php +++ b/src/Listeners/PrepareLivewireForNextOperation.php @@ -2,6 +2,7 @@ namespace Laravel\Octane\Listeners; +use Laravel\Octane\Swoole\Coroutine\LivewireCoroutineMutex; use Livewire\LivewireManager; class PrepareLivewireForNextOperation @@ -20,7 +21,11 @@ public function handle($event): void $manager = $event->sandbox->make(LivewireManager::class); if (method_exists($manager, 'flushState')) { - $manager->flushState(); + $mutex = $event->sandbox->bound(LivewireCoroutineMutex::class) + ? $event->sandbox->make(LivewireCoroutineMutex::class) + : new LivewireCoroutineMutex; + + $mutex->synchronized(fn () => $manager->flushState()); } } } diff --git a/src/OctaneServiceProvider.php b/src/OctaneServiceProvider.php index d98efaa..aa0ed71 100644 --- a/src/OctaneServiceProvider.php +++ b/src/OctaneServiceProvider.php @@ -22,6 +22,7 @@ use Laravel\Octane\FrankenPhp\ServerStateFile as FrankenPhpServerStateFile; use Laravel\Octane\RoadRunner\ServerProcessInspector as RoadRunnerServerProcessInspector; use Laravel\Octane\RoadRunner\ServerStateFile as RoadRunnerServerStateFile; +use Laravel\Octane\Swoole\Coroutine\LivewireCoroutineMutex; use Laravel\Octane\Swoole\ServerProcessInspector as SwooleServerProcessInspector; use Laravel\Octane\Swoole\ServerStateFile as SwooleServerStateFile; use Laravel\Octane\Swoole\SignalDispatcher; @@ -42,6 +43,7 @@ public function register() $this->bindListeners(); $this->app->singleton('octane', Octane::class); + $this->app->singleton(LivewireCoroutineMutex::class); $this->app->singleton('db', function ($app) { return new \Laravel\Octane\Swoole\Database\DatabaseManager($app, $app['db.factory']); @@ -117,6 +119,7 @@ public function boot() $this->registerCommands(); $this->registerHttpTaskHandlingRoutes(); $this->registerPublishing(); + $this->registerLivewireCoroutineMutex(); } /** @@ -159,6 +162,27 @@ protected function bindListeners() $this->app->singleton(Listeners\StopWorkerIfNecessary::class); } + protected function registerLivewireCoroutineMutex(): void + { + $this->app->booted(function (): void { + if (! function_exists('Livewire\\before') || ! function_exists('Livewire\\after')) { + return; + } + + \Livewire\before('render', function (): void { + $this->app->make(LivewireCoroutineMutex::class)->acquire(); + }); + + \Livewire\after('render', function () { + return function ($html) { + $this->app->make(LivewireCoroutineMutex::class)->release(); + + return $html; + }; + }); + }); + } + /** * Register the Octane cache driver. * diff --git a/src/Swoole/Coroutine/LivewireCoroutineMutex.php b/src/Swoole/Coroutine/LivewireCoroutineMutex.php new file mode 100644 index 0000000..880317e --- /dev/null +++ b/src/Swoole/Coroutine/LivewireCoroutineMutex.php @@ -0,0 +1,99 @@ +shouldLock()) { + return; + } + + $depth = (int) Context::get(self::DEPTH_KEY, 0); + + if ($depth > 0) { + Context::set(self::DEPTH_KEY, $depth + 1); + + return; + } + + $this->channel()->pop(); + + Context::set(self::DEPTH_KEY, 1); + } + + public function release(): void + { + if (! $this->shouldLock()) { + return; + } + + $depth = (int) Context::get(self::DEPTH_KEY, 0); + + if ($depth <= 0) { + return; + } + + if ($depth > 1) { + Context::set(self::DEPTH_KEY, $depth - 1); + + return; + } + + Context::delete(self::DEPTH_KEY); + + $this->channel()->push(true); + } + + public function releaseAllForCurrentCoroutine(): void + { + if (! $this->shouldLock()) { + return; + } + + while ((int) Context::get(self::DEPTH_KEY, 0) > 0) { + $this->release(); + } + } + + /** + * @template TValue + * + * @param callable(): TValue $callback + * @return TValue + */ + public function synchronized(callable $callback): mixed + { + $this->acquire(); + + try { + return $callback(); + } finally { + $this->release(); + } + } + + private function shouldLock(): bool + { + return class_exists(\Swoole\Coroutine::class) + && class_exists(Channel::class) + && Context::inCoroutine(); + } + + private function channel(): Channel + { + if (! self::$channel instanceof Channel) { + self::$channel = new Channel(1); + self::$channel->push(true); + } + + return self::$channel; + } +} diff --git a/src/Swoole/Coroutine/RequestScope.php b/src/Swoole/Coroutine/RequestScope.php index 3787c6e..8b96f28 100644 --- a/src/Swoole/Coroutine/RequestScope.php +++ b/src/Swoole/Coroutine/RequestScope.php @@ -1065,6 +1065,7 @@ protected function createViewFactory(Application $sandbox): \Illuminate\Contract /** @var \Illuminate\View\Factory $view */ $view = clone $this->app->make('view'); $view->setContainer($sandbox); + $view->share('__env', $view); $view->share('app', $sandbox); $view->flushState(); diff --git a/src/Worker.php b/src/Worker.php index 1bd8e21..a01666c 100644 --- a/src/Worker.php +++ b/src/Worker.php @@ -22,6 +22,7 @@ use Throwable; use Laravel\Octane\Swoole\Coroutine\Context; use Laravel\Octane\Swoole\Coroutine\CoroutineApplication; +use Laravel\Octane\Swoole\Coroutine\LivewireCoroutineMutex; use Laravel\Octane\Swoole\Coroutine\RequestScope; use Swoole\Coroutine; use Illuminate\Support\Facades\Facade; @@ -135,6 +136,10 @@ public function handle(Request $request, RequestContext $context): void $this->handleWorkerError($e, $sandbox, $request, $context, $responded); } finally { if ($inCoroutine) { + if ($sandbox->bound(LivewireCoroutineMutex::class)) { + $sandbox->make(LivewireCoroutineMutex::class)->releaseAllForCurrentCoroutine(); + } + // Release coroutine-local database connections before flushing // request scope. Flushing first can drop the context references // needed by the pool, leaving its counters exhausted while PDOs diff --git a/tests/Unit/LivewireCoroutineMutexTest.php b/tests/Unit/LivewireCoroutineMutexTest.php new file mode 100644 index 0000000..47249ab --- /dev/null +++ b/tests/Unit/LivewireCoroutineMutexTest.php @@ -0,0 +1,94 @@ +markTestSkipped('Swoole is required for coroutine mutex tests.'); + } + + $events = []; + + \Swoole\Coroutine\run(function () use (&$events): void { + $mutex = new LivewireCoroutineMutex; + $firstAcquired = new Channel(1); + + Coroutine::create(function () use ($mutex, $firstAcquired, &$events): void { + Context::clear(); + + $mutex->acquire(); + $events[] = 'first acquired'; + $firstAcquired->push(true); + + Coroutine::sleep(0.05); + + $events[] = 'first releasing'; + $mutex->release(); + + Context::clear(); + }); + + $firstAcquired->pop(); + + Coroutine::create(function () use ($mutex, &$events): void { + Context::clear(); + + $events[] = 'second waiting'; + $mutex->acquire(); + $events[] = 'second acquired'; + $mutex->release(); + + Context::clear(); + }); + + Coroutine::sleep(0.01); + + $events[] = 'checkpoint'; + + Coroutine::sleep(0.08); + }); + + $this->assertSame([ + 'first acquired', + 'second waiting', + 'checkpoint', + 'first releasing', + 'second acquired', + ], $events); + } + + public function test_it_is_reentrant_within_a_coroutine(): void + { + if (! extension_loaded('swoole') || ! class_exists(Channel::class)) { + $this->markTestSkipped('Swoole is required for coroutine mutex tests.'); + } + + $completed = false; + + \Swoole\Coroutine\run(function () use (&$completed): void { + $mutex = new LivewireCoroutineMutex; + + Context::clear(); + + $mutex->acquire(); + $mutex->acquire(); + $mutex->release(); + $mutex->release(); + + $completed = true; + + Context::clear(); + }); + + $this->assertTrue($completed); + } +} diff --git a/tests/Unit/RequestScopeViewIsolationTest.php b/tests/Unit/RequestScopeViewIsolationTest.php index 3a575e6..61826e0 100644 --- a/tests/Unit/RequestScopeViewIsolationTest.php +++ b/tests/Unit/RequestScopeViewIsolationTest.php @@ -41,7 +41,9 @@ public function test_view_factory_shared_data_is_isolated_per_request_scope(): v $this->assertSame('alpha', $firstView->shared('request_id')); $this->assertSame('global', $firstView->shared('boot_only')); + $this->assertSame($firstView, $firstView->shared('__env')); $this->assertSame($sandbox, $firstView->shared('app')); + $this->assertSame($base->make('view'), $base->make('view')->shared('__env')); $this->assertNull($base->make('view')->shared('request_id')); } finally { Context::clear(); @@ -56,6 +58,7 @@ public function test_view_factory_shared_data_is_isolated_per_request_scope(): v $this->assertSame('global', $secondView->shared('boot_only')); $this->assertNull($secondView->shared('request_id')); $this->assertNull($base->make('view')->shared('request_id')); + $this->assertSame($secondView, $secondView->shared('__env')); $this->assertSame($secondView, $sandbox->make(ViewFactoryContract::class)); } finally { Context::clear();