Skip to content
Merged
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
7 changes: 6 additions & 1 deletion src/Listeners/PrepareLivewireForNextOperation.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Laravel\Octane\Listeners;

use Laravel\Octane\Swoole\Coroutine\LivewireCoroutineMutex;
use Livewire\LivewireManager;

class PrepareLivewireForNextOperation
Expand All @@ -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());
}
}
}
24 changes: 24 additions & 0 deletions src/OctaneServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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']);
Expand Down Expand Up @@ -117,6 +119,7 @@ public function boot()
$this->registerCommands();
$this->registerHttpTaskHandlingRoutes();
$this->registerPublishing();
$this->registerLivewireCoroutineMutex();
}

/**
Expand Down Expand Up @@ -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.
*
Expand Down
99 changes: 99 additions & 0 deletions src/Swoole/Coroutine/LivewireCoroutineMutex.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

namespace Laravel\Octane\Swoole\Coroutine;

use Swoole\Coroutine\Channel;

class LivewireCoroutineMutex
{
private const DEPTH_KEY = 'octane.livewire_mutex_depth';

private static $channel = null;

public function acquire(): void
{
if (! $this->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;
}
}
1 change: 1 addition & 0 deletions src/Swoole/Coroutine/RequestScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
5 changes: 5 additions & 0 deletions src/Worker.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions tests/Unit/LivewireCoroutineMutexTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

namespace Tests\Unit;

use Laravel\Octane\Swoole\Coroutine\Context;
use Laravel\Octane\Swoole\Coroutine\LivewireCoroutineMutex;
use PHPUnit\Framework\TestCase;
use Swoole\Coroutine;
use Swoole\Coroutine\Channel;

class LivewireCoroutineMutexTest extends TestCase
{
public function test_it_serializes_livewire_sections_between_coroutines(): void
{
if (! extension_loaded('swoole') || ! class_exists(Channel::class)) {
$this->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);
}
}
3 changes: 3 additions & 0 deletions tests/Unit/RequestScopeViewIsolationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down
Loading