Skip to content
Open
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
12 changes: 11 additions & 1 deletion src/console/src/Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$this->replaceOutput();
$method = method_exists($this, 'handle') ? 'handle' : '__invoke';

$callback = function () use ($method): int {
// Capture console state before potentially spawning a new coroutine.
// This preserves the context set by Kernel::handle() across coroutine boundaries.
$shouldRunInConsole = $this->app->runningInConsole();

$callback = function () use ($method, $shouldRunInConsole): int {
// Re-establish console context in the new coroutine if it was set
// by Kernel::handle() (CLI entry point) before spawning.
if ($shouldRunInConsole) {
$this->app->markAsRunningInConsole();
}

try {
$this->eventDispatcher?->dispatch(new BeforeHandle($this));
/* @phpstan-ignore-next-line */
Expand Down
24 changes: 24 additions & 0 deletions src/foundation/src/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Closure;
use Hyperf\Collection\Arr;
use Hyperf\Context\Context;
use Hyperf\Di\Definition\DefinitionSourceInterface;
use Hyperf\Macroable\Macroable;
use Hypervel\Container\Container;
Expand Down Expand Up @@ -685,4 +686,27 @@ public function getNamespace(): string

throw new RuntimeException('Unable to detect application namespace.');
}

/**
* Determine if the application is running in the console.
*
* In Swoole's architecture, both HTTP servers and console commands run via CLI,
* so PHP_SAPI detection doesn't work. This method checks a coroutine-local
* Context flag that is set when console commands are executed.
*/
public function runningInConsole(): bool
{
return Context::get('__foundation.running_in_console', false) === true;
}

/**
* Mark the application as running in the console.
*
* This sets a coroutine-local Context flag. Called by the Console Kernel
* before executing commands.
*/
public function markAsRunningInConsole(): void
{
Context::set('__foundation.running_in_console', true);
}
}
2 changes: 2 additions & 0 deletions src/foundation/src/Console/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ public function __construct(
*/
public function handle(InputInterface $input, ?OutputInterface $output = null): mixed
{
$this->app->markAsRunningInConsole();

return $this->getArtisan()->run($input, $output);
}

Expand Down
17 changes: 17 additions & 0 deletions src/foundation/src/Contracts/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,4 +204,21 @@ public function setLocale(string $locale): void;
* @throws RuntimeException
*/
public function getNamespace(): string;

/**
* Determine if the application is running in the console.
*
* In Swoole's architecture, both HTTP servers and console commands run via CLI,
* so PHP_SAPI detection doesn't work. This method checks a coroutine-local
* Context flag that is set when console commands are executed.
*/
public function runningInConsole(): bool;

/**
* Mark the application as running in the console.
*
* This sets a coroutine-local Context flag. Called by the Console Kernel
* before executing commands.
*/
public function markAsRunningInConsole(): void;
}
2 changes: 2 additions & 0 deletions src/support/src/Facades/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
* @method static string getFallbackLocale()
* @method static void setLocale(string $locale)
* @method static string getNamespace()
* @method static bool runningInConsole()
* @method static void markAsRunningInConsole()
* @method static mixed make(string $name, array $parameters = [])
* @method static mixed get(string $id)
* @method static void set(string $name, mixed $entry)
Expand Down
232 changes: 232 additions & 0 deletions tests/Foundation/FoundationRunningInConsoleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
<?php

declare(strict_types=1);

namespace Hypervel\Tests\Foundation;

use Hyperf\Context\Context;
use Hypervel\Console\Contracts\EventMutex;
use Hypervel\Console\Scheduling\Event;
use Hypervel\Foundation\Console\Contracts\Kernel as KernelContract;
use Hypervel\Support\Facades\Artisan;
use Hypervel\Testbench\TestCase;
use Mockery as m;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;

/**
* @internal
* @coversNothing
*/
class FoundationRunningInConsoleTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();

// Reset captured values between tests
CaptureRunningInConsoleCommand::$capturedValue = null;
NestedCallerCommand::$capturedValueBeforeCall = null;
NestedCallerCommand::$capturedValueAfterCall = null;
}

protected function tearDown(): void
{
Context::destroy('__foundation.running_in_console');
m::close();

parent::tearDown();
}

public function testDefaultStateReturnsFalse(): void
{
$this->assertFalse($this->app->runningInConsole());
}

public function testMarkAsRunningInConsoleSetsStateToTrue(): void
{
$this->assertFalse($this->app->runningInConsole());

$this->app->markAsRunningInConsole();

$this->assertTrue($this->app->runningInConsole());
}

public function testMarkAsRunningInConsoleIsIdempotent(): void
{
$this->app->markAsRunningInConsole();
$this->app->markAsRunningInConsole();
$this->app->markAsRunningInConsole();

$this->assertTrue($this->app->runningInConsole());
}

public function testKernelHandleSetsRunningInConsole(): void
{
$this->assertFalse($this->app->runningInConsole());

Artisan::command('test:noop', fn () => 0);

$kernel = $this->app->get(KernelContract::class);
$kernel->handle(
new ArrayInput(['command' => 'test:noop']),
new BufferedOutput()
);

$this->assertTrue($this->app->runningInConsole());
}

public function testArtisanCallDoesNotSetRunningInConsole(): void
{
$this->assertFalse($this->app->runningInConsole());

Artisan::command('test:noop', fn () => 0);
Artisan::call('test:noop');

// Artisan::call() from non-console context should NOT set the flag
$this->assertFalse($this->app->runningInConsole());
}

public function testCodeInsideCliCommandSeesRunningInConsoleTrue(): void
{
// This is the primary use case: code running inside a CLI command
// (like a model's global scope) should see runningInConsole() = true

$this->registerCaptureCommand();

$kernel = $this->app->get(KernelContract::class);
$kernel->handle(
new ArrayInput(['command' => 'test:capture']),
new BufferedOutput()
);

$this->assertTrue(CaptureRunningInConsoleCommand::$capturedValue);
}

public function testCodeInsideKernelCallWithoutPriorHandleSeesRunningInConsoleFalse(): void
{
// When Kernel::call() is used without a prior Kernel::handle() (e.g., from
// HTTP context), code inside the command should see runningInConsole() = false

$this->registerCaptureCommand();

$kernel = $this->app->get(KernelContract::class);
$kernel->call('test:capture');

$this->assertFalse(CaptureRunningInConsoleCommand::$capturedValue);
}

public function testNestedCommandInheritsConsoleContext(): void
{
// When a CLI command calls another command via Artisan::call(),
// the nested command should inherit the console context

$this->registerCaptureCommand();
$this->registerNestedCallerCommand();

$kernel = $this->app->get(KernelContract::class);
$kernel->handle(
new ArrayInput(['command' => 'test:nested-caller']),
new BufferedOutput()
);

// Parent command should see true
$this->assertTrue(NestedCallerCommand::$capturedValueBeforeCall);

// Nested command (called via Artisan::call) should also see true
$this->assertTrue(CaptureRunningInConsoleCommand::$capturedValue);

// Parent should still see true after the call
$this->assertTrue(NestedCallerCommand::$capturedValueAfterCall);
}

public function testAppHelperReflectsRunningInConsoleState(): void
{
$this->assertFalse(app()->runningInConsole());

$this->app->markAsRunningInConsole();

$this->assertTrue(app()->runningInConsole());
}

public function testScheduledCommandInheritsConsoleContext(): void
{
// Scheduled commands are run via Kernel::call() from schedule:run,
// which is itself invoked via Kernel::handle(). The scheduled command
// should inherit the console context from the parent schedule:run command.

$this->registerCaptureCommand();

// Simulate the schedule:run command having set the console context
// (this happens when `php artisan schedule:run` calls Kernel::handle())
$this->app->markAsRunningInConsole();

// Create a scheduled event that runs our capture command
$event = new Event(m::mock(EventMutex::class), 'test:capture');

// Run the scheduled event - this calls Kernel::call() internally
$event->run($this->app);

// The command should have inherited the console context
$this->assertTrue(
CaptureRunningInConsoleCommand::$capturedValue,
'Scheduled command should inherit runningInConsole() = true from schedule:run'
);
}

private function registerCaptureCommand(): void
{
$this->app->bind(CaptureRunningInConsoleCommand::class, CaptureRunningInConsoleCommand::class);
$this->app->get(KernelContract::class)->registerCommand(CaptureRunningInConsoleCommand::class);
}

private function registerNestedCallerCommand(): void
{
$this->app->bind(NestedCallerCommand::class, NestedCallerCommand::class);
$this->app->get(KernelContract::class)->registerCommand(NestedCallerCommand::class);
}
}

/**
* Test command that captures runningInConsole() value during execution.
*/
class CaptureRunningInConsoleCommand extends \Hypervel\Console\Command
{
protected ?string $signature = 'test:capture';

protected string $description = 'Captures runningInConsole value';

public static ?bool $capturedValue = null;

public function handle(): int
{
self::$capturedValue = app()->runningInConsole();

return self::SUCCESS;
}
}

/**
* Test command that calls another command via Artisan::call().
*/
class NestedCallerCommand extends \Hypervel\Console\Command
{
protected ?string $signature = 'test:nested-caller';

protected string $description = 'Calls another command via Artisan::call';

public static ?bool $capturedValueBeforeCall = null;

public static ?bool $capturedValueAfterCall = null;

public function handle(): int
{
self::$capturedValueBeforeCall = app()->runningInConsole();

\Hypervel\Support\Facades\Artisan::call('test:capture');

self::$capturedValueAfterCall = app()->runningInConsole();

return self::SUCCESS;
}
}