From 829a6e12bb9c9073518410179bf707aaee88c7cf Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:02:07 +0000 Subject: [PATCH 1/2] Add runningInConsole() method to Application Implements Laravel-compatible runningInConsole() for Swoole's coroutine architecture where PHP_SAPI detection doesn't work (both HTTP server and CLI commands run via CLI). - Add runningInConsole() and markAsRunningInConsole() to Application - Kernel::handle() sets the console context flag for CLI entry points - Command::execute() preserves context across coroutine boundaries - Uses __foundation.running_in_console Context key (framework convention) Semantics: - CLI commands via Kernel::handle(): true - Artisan::call() from HTTP: false (inherits caller context) - Nested commands: inherit parent context - Scheduled commands: true (inherited from schedule:run) - Queue jobs: false --- src/console/src/Command.php | 12 +- src/foundation/src/Application.php | 24 ++ src/foundation/src/Console/Kernel.php | 2 + src/foundation/src/Contracts/Application.php | 17 + .../FoundationRunningInConsoleTest.php | 363 ++++++++++++++++++ 5 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 tests/Foundation/FoundationRunningInConsoleTest.php 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/tests/Foundation/FoundationRunningInConsoleTest.php b/tests/Foundation/FoundationRunningInConsoleTest.php new file mode 100644 index 000000000..c9c01f782 --- /dev/null +++ b/tests/Foundation/FoundationRunningInConsoleTest.php @@ -0,0 +1,363 @@ +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 testCodeInsideArtisanCallFromHttpSeesRunningInConsoleFalse(): void + { + // When Artisan::call() is used from HTTP context (e.g., controller), + // 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' + ); + } + + public function testQueueJobSeesRunningInConsoleFalse(): void + { + // Queue jobs run in the queue worker context, which does NOT go through + // Kernel::handle(). Jobs should see runningInConsole() = false. + + // Ensure we're NOT in console context (simulating queue worker) + $this->assertFalse($this->app->runningInConsole()); + + // Create and fire a fake job + $job = new CaptureRunningInConsoleJob(); + $job->fire(); + + // The job should see runningInConsole() = false + $this->assertFalse( + CaptureRunningInConsoleJob::$capturedValue, + 'Queue job should see runningInConsole() = false' + ); + } + + 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; + } +} + +/** + * Fake queue job that captures runningInConsole() value during execution. + */ +class CaptureRunningInConsoleJob implements JobContract +{ + public static ?bool $capturedValue = null; + + public function fire(): void + { + self::$capturedValue = app()->runningInConsole(); + } + + public function getJobId(): string|int|null + { + return 'test-job-id'; + } + + public function release(int $delay = 0): void + { + } + + public function isReleased(): bool + { + return false; + } + + public function delete(): void + { + } + + public function isDeleted(): bool + { + return false; + } + + public function isDeletedOrReleased(): bool + { + return false; + } + + public function attempts(): int + { + return 1; + } + + public function hasFailed(): bool + { + return false; + } + + public function markAsFailed(): void + { + } + + public function fail(?Throwable $e = null): void + { + } + + public function maxTries(): ?int + { + return null; + } + + public function maxExceptions(): ?int + { + return null; + } + + public function timeout(): ?int + { + return null; + } + + public function retryUntil(): ?int + { + return null; + } + + public function getName(): string + { + return 'CaptureRunningInConsoleJob'; + } + + public function resolveName(): string + { + return self::class; + } + + public function getConnectionName(): string + { + return 'sync'; + } + + public function getQueue(): string + { + return 'default'; + } + + public function getRawBody(): string + { + return '{}'; + } + + public function uuid(): ?string + { + return 'test-uuid'; + } +} From d671690fad8b745d78334fc7e4c0e16339d5a137 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:43:36 +0000 Subject: [PATCH 2/2] Clean up runningInConsole tests and update App facade docs - Remove queue job test that didn't actually test the PR's functionality - Remove CaptureRunningInConsoleJob fixture (100+ lines of boilerplate) - Rename test method to accurately reflect what it tests - Regenerate App facade docblocks to include new methods --- src/support/src/Facades/App.php | 2 + .../FoundationRunningInConsoleTest.php | 137 +----------------- 2 files changed, 5 insertions(+), 134 deletions(-) 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 index c9c01f782..37e04f900 100644 --- a/tests/Foundation/FoundationRunningInConsoleTest.php +++ b/tests/Foundation/FoundationRunningInConsoleTest.php @@ -8,13 +8,11 @@ use Hypervel\Console\Contracts\EventMutex; use Hypervel\Console\Scheduling\Event; use Hypervel\Foundation\Console\Contracts\Kernel as KernelContract; -use Hypervel\Queue\Contracts\Job as JobContract; 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; -use Throwable; /** * @internal @@ -30,7 +28,6 @@ protected function setUp(): void CaptureRunningInConsoleCommand::$capturedValue = null; NestedCallerCommand::$capturedValueBeforeCall = null; NestedCallerCommand::$capturedValueAfterCall = null; - CaptureRunningInConsoleJob::$capturedValue = null; } protected function tearDown(): void @@ -106,10 +103,10 @@ public function testCodeInsideCliCommandSeesRunningInConsoleTrue(): void $this->assertTrue(CaptureRunningInConsoleCommand::$capturedValue); } - public function testCodeInsideArtisanCallFromHttpSeesRunningInConsoleFalse(): void + public function testCodeInsideKernelCallWithoutPriorHandleSeesRunningInConsoleFalse(): void { - // When Artisan::call() is used from HTTP context (e.g., controller), - // code inside the command should see runningInConsole() = false + // 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(); @@ -177,25 +174,6 @@ public function testScheduledCommandInheritsConsoleContext(): void ); } - public function testQueueJobSeesRunningInConsoleFalse(): void - { - // Queue jobs run in the queue worker context, which does NOT go through - // Kernel::handle(). Jobs should see runningInConsole() = false. - - // Ensure we're NOT in console context (simulating queue worker) - $this->assertFalse($this->app->runningInConsole()); - - // Create and fire a fake job - $job = new CaptureRunningInConsoleJob(); - $job->fire(); - - // The job should see runningInConsole() = false - $this->assertFalse( - CaptureRunningInConsoleJob::$capturedValue, - 'Queue job should see runningInConsole() = false' - ); - } - private function registerCaptureCommand(): void { $this->app->bind(CaptureRunningInConsoleCommand::class, CaptureRunningInConsoleCommand::class); @@ -252,112 +230,3 @@ public function handle(): int return self::SUCCESS; } } - -/** - * Fake queue job that captures runningInConsole() value during execution. - */ -class CaptureRunningInConsoleJob implements JobContract -{ - public static ?bool $capturedValue = null; - - public function fire(): void - { - self::$capturedValue = app()->runningInConsole(); - } - - public function getJobId(): string|int|null - { - return 'test-job-id'; - } - - public function release(int $delay = 0): void - { - } - - public function isReleased(): bool - { - return false; - } - - public function delete(): void - { - } - - public function isDeleted(): bool - { - return false; - } - - public function isDeletedOrReleased(): bool - { - return false; - } - - public function attempts(): int - { - return 1; - } - - public function hasFailed(): bool - { - return false; - } - - public function markAsFailed(): void - { - } - - public function fail(?Throwable $e = null): void - { - } - - public function maxTries(): ?int - { - return null; - } - - public function maxExceptions(): ?int - { - return null; - } - - public function timeout(): ?int - { - return null; - } - - public function retryUntil(): ?int - { - return null; - } - - public function getName(): string - { - return 'CaptureRunningInConsoleJob'; - } - - public function resolveName(): string - { - return self::class; - } - - public function getConnectionName(): string - { - return 'sync'; - } - - public function getQueue(): string - { - return 'default'; - } - - public function getRawBody(): string - { - return '{}'; - } - - public function uuid(): ?string - { - return 'test-uuid'; - } -}