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
14 changes: 13 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ jobs:

name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }}

# The Redis pool and coroutine Redis tests skip without a server on 127.0.0.1:6379.
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 2s
--health-timeout 2s
--health-retries 15

steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -36,7 +48,7 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: "${{ matrix.php }}"
extensions: swoole
extensions: swoole, redis
coverage: none
tools: composer:v2
cache: composer
Expand Down
86 changes: 85 additions & 1 deletion src/Swoole/Coroutine/CoroutineApplication.php
Original file line number Diff line number Diff line change
Expand Up @@ -1272,20 +1272,51 @@ public function version()
* @param callable|string $callback
* @return $this
*/
/**
* Register a terminating callback.
*
* A callback registered during a request belongs to that request. Forwarded to the
* base application it ran at the end of every later request and was never removed:
* the Blade engine, which Octane resolves again on every request, registers one each
* time, so a worker ran and kept one more callback per request it had served.
*
* @param callable|string $callback
* @return $this
*/
public function terminating($callback)
{
$scope = $this->getRequestScope();

if ($scope instanceof RequestScope) {
$scope->addTerminatingCallback($callback);

return $this;
}

$this->getCurrentApp()->terminating($callback);

return $this;
}

/**
* Terminate the application.
*
* Runs the callbacks registered at boot, then the ones this request registered.
*
* @return void
*/
public function terminate()
{
$this->getCurrentApp()->terminate();

$scope = $this->getRequestScope();

if ($scope instanceof RequestScope) {
// A callback may register another one; it runs in the same pass.
for ($index = 0; ($callback = $scope->terminatingCallback($index)) !== null; $index++) {
$this->call($callback);
}
}
}

/**
Expand Down Expand Up @@ -1694,14 +1725,67 @@ public function provideFacades($namespace)
return $this->getCurrentApp()->provideFacades($namespace);
}

/**
* Register a rebinding callback.
*
* A rebinding of a request-scoped abstract belongs to the request. Laravel's
* AuthManager calls refresh('request', $guard, 'setRequest') for every guard
* it creates, and the auth manager is request-scoped, so delegating to the
* base application kept one callback (and the guard, request, session and
* user it captures) per request for the life of the worker. It also handed
* the guard the base application's request instead of the current one.
*
* @param string $abstract
* @param \Closure $callback
* @return mixed
*/
public function rebinding($abstract, \Closure $callback)
{
$scope = $this->getRequestScope();

if ($scope instanceof RequestScope
&& is_string($key = $this->normalizeAbstract($abstract))
&& $scope->has($key)) {
$scope->addReboundCallback($key, $callback);

return $this->make($key);
}

return $this->getCurrentApp()->rebinding($abstract, $callback);
}

public function refresh($abstract, $target, $method)
{
return $this->getCurrentApp()->refresh($abstract, $target, $method);
return $this->rebinding($abstract, function ($app, $instance) use ($target, $method) {
$target->{$method}($instance);
});
}

/**
* Fire the "rebound" callbacks for the given abstract type.
*
* Request-local callbacks run first, then any registered on this proxy.
*
* @param string $abstract
* @return void
*/
protected function rebound($abstract)
{
$scope = $this->getRequestScope();

if ($scope instanceof RequestScope && is_string($key = $this->normalizeAbstract($abstract))) {
$callbacks = $scope->reboundCallbacks($key);

if ($callbacks !== []) {
$instance = $this->make($key);

foreach ($callbacks as $callback) {
$callback($this, $instance);
}
}
}

parent::rebound($abstract);
}

public function registerConfiguredProviders()
Expand Down
89 changes: 89 additions & 0 deletions src/Swoole/Coroutine/RequestScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,20 @@ class RequestScope
*/
private array $resolvedBindings = [];

/**
* Rebinding callbacks registered for request-scoped abstracts.
*
* @var array<string, list<\Closure>>
*/
private array $reboundCallbacks = [];

/**
* Callbacks to run when this request terminates.
*
* @var list<callable|string>
*/
private array $terminatingCallbacks = [];

/**
* The base application instance (shared, read-only reference).
*
Expand Down Expand Up @@ -136,6 +150,51 @@ public function forget(string $key): void
unset($this->bindings[$key], $this->bindingDefinitions[$key], $this->resolvedBindings[$key]);
}

/**
* Register a rebinding callback that lives as long as this request.
*
* @param string $key
* @param \Closure $callback
* @return void
*/
public function addReboundCallback(string $key, Closure $callback): void
{
$this->reboundCallbacks[$key][] = $callback;
}

/**
* Register a callback to run when this request terminates.
*
* @param callable|string $callback
* @return void
*/
public function addTerminatingCallback($callback): void
{
$this->terminatingCallbacks[] = $callback;
}

/**
* Get the request's terminating callback at the given position, if any.
*
* @param int $index
* @return callable|string|null
*/
public function terminatingCallback(int $index)
{
return $this->terminatingCallbacks[$index] ?? null;
}

/**
* Get the request-local rebinding callbacks for the given abstract.
*
* @param string $key
* @return list<\Closure>
*/
public function reboundCallbacks(string $key): array
{
return $this->reboundCallbacks[$key] ?? [];
}

/**
* Register a request-local binding.
*
Expand Down Expand Up @@ -288,6 +347,7 @@ protected function resolveSpecializedBinding(string $key, Application $sandbox)

return match ($key) {
'auth' => $this->createAuthManager($sandbox),
\Illuminate\Bus\Dispatcher::class => $this->createBusDispatcher($sandbox),
'auth.driver' => $this->createAuthDriver($sandbox),
'cache', \Illuminate\Contracts\Cache\Factory::class => $this->createCacheManager($sandbox),
'cache.store', \Illuminate\Contracts\Cache\Repository::class => $this->createCacheStore($sandbox),
Expand Down Expand Up @@ -713,6 +773,8 @@ public function clear(): void
$this->bindings = [];
$this->bindingDefinitions = [];
$this->resolvedBindings = [];
$this->reboundCallbacks = [];
$this->terminatingCallbacks = [];
$this->configCloned = false;
}

Expand Down Expand Up @@ -1394,6 +1456,33 @@ protected function createInertiaResponseFactory(): \Inertia\ResponseFactory
return new \Inertia\ResponseFactory;
}

/**
* Create a bus dispatcher bound to the current request.
*
* dispatch(...)->afterResponse() and Bus::batch() register a terminating callback on
* the dispatcher's container. On the worker's singleton that is the base application,
* whose terminating list is never cleared and runs at the end of every request: each
* after-response job ran again at the end of every later request on the worker. A
* clone holding the request's container keeps the callback with this request. A fake
* (Bus::fake()) is not a Dispatcher and stays shared.
*
* @param \Illuminate\Foundation\Application $sandbox
* @return \Illuminate\Bus\Dispatcher|null
*/
protected function createBusDispatcher(Application $sandbox): ?\Illuminate\Bus\Dispatcher
{
$dispatcher = $this->app->make(\Illuminate\Bus\Dispatcher::class);

if (! $dispatcher instanceof \Illuminate\Bus\Dispatcher) {
return null;
}

$dispatcher = clone $dispatcher;
$this->setObjectProperty($dispatcher, 'container', $sandbox);

return $dispatcher;
}

/**
* Create an isolated Vite instance for the current coroutine.
*
Expand Down
Loading
Loading