diff --git a/src/console/src/Command.php b/src/console/src/Command.php index 2fb62ce4b..69414a2b3 100644 --- a/src/console/src/Command.php +++ b/src/console/src/Command.php @@ -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 */ diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php index f07b7e70f..afdb4c028 100644 --- a/src/foundation/src/Application.php +++ b/src/foundation/src/Application.php @@ -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; @@ -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); + } } diff --git a/src/foundation/src/Console/Kernel.php b/src/foundation/src/Console/Kernel.php index bc34fc5cb..2cc817183 100644 --- a/src/foundation/src/Console/Kernel.php +++ b/src/foundation/src/Console/Kernel.php @@ -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); } diff --git a/src/foundation/src/Contracts/Application.php b/src/foundation/src/Contracts/Application.php index 21fb7a9aa..9cfe54966 100644 --- a/src/foundation/src/Contracts/Application.php +++ b/src/foundation/src/Contracts/Application.php @@ -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; } diff --git a/src/support/src/Facades/App.php b/src/support/src/Facades/App.php index 7430da72b..4e5def5b0 100644 --- a/src/support/src/Facades/App.php +++ b/src/support/src/Facades/App.php @@ -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) diff --git a/tests/Foundation/FoundationRunningInConsoleTest.php b/tests/Foundation/FoundationRunningInConsoleTest.php new file mode 100644 index 000000000..37e04f900 --- /dev/null +++ b/tests/Foundation/FoundationRunningInConsoleTest.php @@ -0,0 +1,232 @@ +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; + } +}