diff --git a/src/Phaseolies/Console/Commands/Cron/CronDaemonCommand.php b/src/Phaseolies/Console/Commands/Cron/CronDaemonCommand.php index d2656d7f..ba8913c2 100644 --- a/src/Phaseolies/Console/Commands/Cron/CronDaemonCommand.php +++ b/src/Phaseolies/Console/Commands/Cron/CronDaemonCommand.php @@ -3,6 +3,7 @@ namespace Phaseolies\Console\Commands\Cron; use Phaseolies\Console\Schedule\Command; +use Phaseolies\Console\Schedule\SchedulePool; class CronDaemonCommand extends Command { @@ -267,17 +268,7 @@ protected function isDaemonRunning(): bool */ protected function isProcessRunning(int $pid): bool { - if ($pid <= 0) { - return false; - } - - if (function_exists('posix_kill')) { - return posix_kill($pid, 0); - } - - // Fallback - $output = shell_exec(sprintf("ps -p %d -o pid=", $pid)); - return !empty(trim($output)); + return SchedulePool::isProcessRunning($pid); } /** diff --git a/src/Phaseolies/Console/Commands/Cron/CronFinishCommand.php b/src/Phaseolies/Console/Commands/Cron/CronFinishCommand.php index 9cccbb00..4ed561fb 100644 --- a/src/Phaseolies/Console/Commands/Cron/CronFinishCommand.php +++ b/src/Phaseolies/Console/Commands/Cron/CronFinishCommand.php @@ -32,21 +32,30 @@ public function handle(): int $exitCode = (int)$this->argument('exit_code'); // Find and clean up the process - $pidFiles = glob(sys_get_temp_dir() . "/doppar_cron_lock_*.pid"); + $directories = array_unique(array_filter([ + $this->scheduleDirectory(), + sys_get_temp_dir(), + ])); - foreach ($pidFiles as $pidFile) { - $processInfo = json_decode(file_get_contents($pidFile), true); + foreach ($directories as $directory) { + foreach (glob($directory . '/doppar_cron_lock_*.pid') ?: [] as $pidFile) { + $processInfo = json_decode((string) @file_get_contents($pidFile), true); + + if (!is_array($processInfo) || ($processInfo['finish_id'] ?? null) !== $finishId) { + continue; + } - if ($processInfo['finish_id'] === $finishId) { if ($shouldReleaseLock) { - $lockFile = str_replace('.pid', '', $pidFile); + $lockFile = substr($pidFile, 0, -strlen('.pid')); + if (file_exists($lockFile)) { - unlink($lockFile); + @unlink($lockFile); } } - unlink($pidFile); - break; + @unlink($pidFile); + + break 2; } } @@ -57,4 +66,18 @@ public function handle(): int return $exitCode; } } + + /** + * Get the directory that holds the scheduler's lock files + * + * @return string|null + */ + protected function scheduleDirectory(): ?string + { + try { + return storage_path('schedule'); + } catch (\Throwable) { + return null; + } + } } diff --git a/src/Phaseolies/Console/Commands/Cron/CronRunCommand.php b/src/Phaseolies/Console/Commands/Cron/CronRunCommand.php index 93576de6..ea7d9b5c 100644 --- a/src/Phaseolies/Console/Commands/Cron/CronRunCommand.php +++ b/src/Phaseolies/Console/Commands/Cron/CronRunCommand.php @@ -2,14 +2,22 @@ namespace Phaseolies\Console\Commands\Cron; -use App\Schedule\Schedule; use Phaseolies\Console\Schedule\Command; use Phaseolies\Console\Schedule\SchedulePool; +use Symfony\Component\Console\Input\ArgvInput; +use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Process\Process; use React\EventLoop\Loop; class CronRunCommand extends Command { + /** + * The application class that registers the scheduled commands + * + * @var string + */ + protected const SCHEDULE_CLASS = 'App\\Schedule\\Schedule'; + /** * The name and signature of the console command. * @@ -31,6 +39,20 @@ class CronRunCommand extends Command */ protected $lastExecution = []; + /** + * Number of scheduled commands that failed to start or exited with an error + * + * @var int + */ + protected int $failedCommands = 0; + + /** + * Handle of the lock file the daemon holds for as long as it runs + * + * @var resource|null + */ + protected $daemonLock = null; + /** * Execute the console command. * @@ -55,8 +77,11 @@ public function handle(): int protected function runStandardMode(): int { return $this->executeWithTiming(function () { - $schedule = new Schedule(); - $schedule->schedule($schedule); + $schedule = $this->makeSchedule(); + + if ($schedule === null) { + return Command::FAILURE; + } $allCommands = $schedule->getCommands(); @@ -97,10 +122,39 @@ protected function runStandardMode(): int $this->displayInfo('No scheduled commands are ready to run.'); } + // A task that could not run must not look like a clean run to cron + // monitors, or to anyone reading the exit status. + if ($this->failedCommands > 0) { + $this->displayError($this->failedCommands . ' scheduled command(s) failed.'); + + return Command::FAILURE; + } + return Command::SUCCESS; }); } + /** + * Build the application's schedule + * + * @return object|null + */ + protected function makeSchedule(): ?object + { + $class = self::SCHEDULE_CLASS; + + if (!class_exists($class)) { + $this->displayError("The schedule class {$class} was not found. Create it, or remove cron:run from your crontab."); + + return null; + } + + $schedule = new $class(); + $schedule->schedule($schedule); + + return $schedule; + } + /** * Run in daemon mode for second-based schedules using React PHP Event Loop * @@ -108,6 +162,16 @@ protected function runStandardMode(): int */ protected function runDaemonMode(): int { + // Only one daemon may run. The lock is held for the daemon's whole life + // and is released by the OS if it dies, so it can never go stale, and + // two starts at the same instant cannot both win. This makes it safe to + // keep `cron:run --daemon` in the crontab. + if (!$this->acquireDaemonLock()) { + $this->displayInfo('The cron daemon is already running; nothing to do.'); + + return Command::SUCCESS; + } + $this->displayInfo('Starting doppar cron daemon...'); $this->displayInfo('Monitoring for second-based schedules...'); $this->displayInfo('Press Ctrl+C to stop'); @@ -140,8 +204,11 @@ protected function runDaemonMode(): int // Check every second for due commands Loop::addPeriodicTimer(1.0, function () { try { - $schedule = new Schedule(); - $schedule->schedule($schedule); + $schedule = $this->makeSchedule(); + + if ($schedule === null) { + return; + } $allCommands = $schedule->getCommands(); @@ -275,39 +342,61 @@ protected function executeCommand($command, bool $isSecondBased = false): void } else { $this->runInForeground($command, $env); } - } catch (\Exception $e) { + } catch (\Throwable $e) { + $this->failedCommands++; $this->displayError('Error executing command: ' . $e->getMessage()); } } /** - * Check if daemon is currently running + * Take the exclusive daemon lock without waiting * * @return bool */ - protected function isDaemonRunning(): bool + protected function acquireDaemonLock(): bool { - $pidFile = $this->getDaemonPidFile(); + $file = dirname($this->getDaemonPidFile()) . '/cron_daemon.lock'; + $dir = dirname($file); - if (!file_exists($pidFile)) { - return false; + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + + $handle = @fopen($file, 'c'); + + if ($handle === false) { + // Storage is not writable; the PID file check below is the best we can do. + return !$this->isDaemonRunning(); } - $data = @json_decode(file_get_contents($pidFile), true); - $pid = $data['pid'] ?? 0; + if (!flock($handle, LOCK_EX | LOCK_NB)) { + fclose($handle); - if ($pid <= 0) { return false; } - // Check if process is actually running - if (function_exists('posix_kill')) { - return posix_kill($pid, 0); + $this->daemonLock = $handle; + + return true; + } + + /** + * Check if daemon is currently running + * + * @return bool + */ + protected function isDaemonRunning(): bool + { + $pidFile = $this->getDaemonPidFile(); + + if (!file_exists($pidFile)) { + return false; } - // Fallback for systems without posix_kill - $output = shell_exec(sprintf("ps -p %d -o pid=", $pid)); - return !empty(trim($output)); + $data = @json_decode((string) @file_get_contents($pidFile), true); + $pid = (int) ($data['pid'] ?? 0); + + return SchedulePool::isProcessRunning($pid); } /** @@ -344,7 +433,13 @@ protected function cleanupDaemonPid(): void $pidFile = $this->getDaemonPidFile(); if (file_exists($pidFile)) { - unlink($pidFile); + @unlink($pidFile); + } + + if ($this->daemonLock !== null) { + flock($this->daemonLock, LOCK_UN); + fclose($this->daemonLock); + $this->daemonLock = null; } } @@ -358,10 +453,78 @@ protected function getDaemonPidFile(): string return storage_path('schedule/cron_daemon.pid'); } + /** + * Build the shell command that runs a task detached from this process + * + * @param string $command + * @param string $logFile + * @param string $finishId + * @param bool $releaseLock + * @param array $env + * @return string + */ + protected function buildBackgroundCommand( + string $command, + string $logFile, + string $finishId, + bool $releaseLock, + array $env = [] + ): string { + $php = escapeshellarg(SchedulePool::phpBinary()); + $pool = escapeshellarg(SchedulePool::poolScript()); + $log = escapeshellarg($logFile); + $arguments = implode(' ', array_map('escapeshellarg', SchedulePool::splitCommand($command))); + + $assignments = ''; + + foreach ($env as $name => $value) { + if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', (string) $name)) { + $assignments .= $name . '=' . escapeshellarg((string) $value) . ' '; + } + } + + return sprintf( + '(cd %s && %s%s %s %s >> %s 2>&1 ; %s %s cron:finish %s %d $? >> %s 2>&1) > /dev/null 2>&1 < /dev/null & echo $!', + escapeshellarg(base_path()), + $assignments, + $php, + $pool, + $arguments, + $log, + $php, + $pool, + escapeshellarg($finishId), + $releaseLock ? 1 : 0, + $log + ); + } + + /** + * Start a shell command that detaches itself, and return its PID + * + * @param string $shellCommand + * @return int|null + */ + protected function startDetached(string $shellCommand): ?int + { + return SchedulePool::startDetached($shellCommand); + } + + /** + * Start a command in the background and return immediately + * + * @param mixed $command + * @param array $env + * @return void + */ protected function runInBackground($command, $env): void { - $phpBinary = PHP_BINARY; - $poolScript = 'pool'; + if (SchedulePool::isWindows()) { + $this->displayWarning('Background execution is not supported on Windows; running in the foreground: ' . $command->getCommand()); + $this->runInForeground($command, $env); + + return; + } $finishId = uniqid('cron_finish_', true); @@ -385,25 +548,34 @@ protected function runInBackground($command, $env): void mkdir($lockDir, 0755, true); } - $commandParts = implode(' ', array_map('escapeshellarg', preg_split('/\s+/', trim($command->getCommand())))); + $flags = array_intersect_key($env, array_flip([ + 'APP_RUNNING_IN_CONSOLE', + 'APP_SCHEDULE_RUNNING', + 'APP_SECOND_SCHEDULE', + ])); - $commandString = sprintf( - '(%s %s %s >> %s 2>&1 ; %s %s cron:finish %s %d $? >> %s 2>&1) & echo $!', - escapeshellarg($phpBinary), - escapeshellarg($poolScript), - $commandParts, - escapeshellarg($logFile), - escapeshellarg($phpBinary), - escapeshellarg($poolScript), - escapeshellarg($finishId), - $command->withoutOverlapping ? 1 : 0, - escapeshellarg($logFile) + $commandString = $this->buildBackgroundCommand( + $command->getCommand(), + $logFile, + $finishId, + (bool) $command->withoutOverlapping, + $flags ); - $pid = (int) shell_exec($commandString); + $pid = $this->startDetached($commandString); - if (empty($pid)) { - throw new \RuntimeException('Failed to start background process'); + if ($pid === null) { + // shell_exec, exec and proc_open are all unavailable (or the shell + // returned no PID). Do not lose the task: run it here instead. + $this->displayWarning('Could not start a background process; running in the foreground: ' . $command->getCommand()); + + if ($command->withoutOverlapping) { + $command->releaseLock(); + } + + $this->runInForeground($command, $env); + + return; } $processInfo = [ @@ -443,7 +615,9 @@ protected function runInBackground($command, $env): void } } - file_put_contents( + // Best effort: the log path can be unwritable, and that must not turn a + // started job into a failed run. + @file_put_contents( $logFile, sprintf( "[%s] Process started (PID: %d)\nCommand: %s\nProcess Info: %s\n\n", @@ -456,28 +630,93 @@ protected function runInBackground($command, $env): void ); } + /** + * Run a command to completion and record whether it succeeded + * + * @param mixed $command + * @param array $env + * @return void + */ protected function runInForeground($command, $env): void { if ($command->withoutOverlapping) { $command->lock(); } - $process = new Process(array_merge(['php', 'pool'], preg_split('/\s+/', trim($command->getCommand()))), base_path(), $env); - $process->setTimeout(null); - $process->run(); + try { + $result = $this->runToCompletion($command->getCommand(), $env); - if ($process->isSuccessful()) { - // Only show success for non-second-based - if (!$command->isSecondSchedule()) { - $this->info('Success: ' . $command->getCommand()); + if ($result['code'] === 0) { + // Only show success for non-second-based + if (!$command->isSecondSchedule()) { + $this->info('Success: ' . $command->getCommand()); + } + } else { + $this->failedCommands++; + $this->displayError('Error: ' . $command->getCommand() . ' (exit code ' . $result['code'] . ')'); + $this->displayError('Output: ' . ($result['stderr'] !== '' ? $result['stderr'] : $result['stdout'])); + } + } finally { + if ($command->withoutOverlapping) { + $command->releaseLock(); } - } else { - $this->displayError('Error: ' . $command->getCommand()); - $this->displayError('Output: ' . $process->getErrorOutput()); } + } - if ($command->withoutOverlapping) { - $command->releaseLock(); + /** + * Run a pool command and wait for it. + * + * @param string $command + * @param array $env + * @return array{code: int, stdout: string, stderr: string} + */ + protected function runToCompletion(string $command, array $env): array + { + if (SchedulePool::isFunctionEnabled('proc_open')) { + $process = new Process(SchedulePool::buildProcessArguments($command), base_path(), $env); + $process->setTimeout(null); + $process->run(); + + return [ + 'code' => (int) $process->getExitCode(), + 'stdout' => $process->getOutput(), + 'stderr' => $process->getErrorOutput(), + ]; } + + if (SchedulePool::isFunctionEnabled('exec')) { + $arguments = implode(' ', array_map('escapeshellarg', SchedulePool::buildProcessArguments($command))); + $lines = []; + $code = 0; + + exec(sprintf('cd %s && %s 2>&1', escapeshellarg(base_path()), $arguments), $lines, $code); + + return ['code' => $code, 'stdout' => implode("\n", $lines), 'stderr' => '']; + } + + return $this->runInProcess($command); + } + + /** + * Run a pool command through the console application in this process + * + * @param string $command + * @return array{code: int, stdout: string, stderr: string} + */ + protected function runInProcess(string $command): array + { + $application = $this->getApplication(); + $tokens = SchedulePool::splitCommand($command); + + if ($application === null || $tokens === []) { + throw new \RuntimeException( + 'Cannot run scheduled commands: proc_open, exec and shell_exec are disabled on this host.' + ); + } + + $output = new BufferedOutput(); + $code = $application->find($tokens[0])->run(new ArgvInput(array_merge(['pool'], $tokens)), $output); + + return ['code' => $code, 'stdout' => $output->fetch(), 'stderr' => '']; } } diff --git a/src/Phaseolies/Console/Schedule/SchedulePool.php b/src/Phaseolies/Console/Schedule/SchedulePool.php index 884eff08..a6ba95ab 100644 --- a/src/Phaseolies/Console/Schedule/SchedulePool.php +++ b/src/Phaseolies/Console/Schedule/SchedulePool.php @@ -46,10 +46,7 @@ public static function buildEnv(array $overrides = []): array */ public static function call(string $command, bool $background = false): array { - $commandArray = array_merge( - ['php', 'pool'], - preg_split('/\s+/', trim($command)) - ); + $commandArray = self::buildProcessArguments($command); $process = new Process( $commandArray, @@ -118,16 +115,215 @@ public static function getRunningProcesses(): array /** * Check if a process is running by PID * + * @param int $pid * @return bool */ public static function isProcessRunning(int $pid): bool { - if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { - $output = shell_exec("tasklist /FI \"PID eq $pid\""); + // A corrupt or hand-edited lock file can hold any number; posix_kill() + // throws for values outside the range of a PID. + if ($pid <= 0 || $pid > 2147483647) { + return false; + } + + if (self::isWindows()) { + if (!self::isFunctionEnabled('shell_exec')) { + return false; + } + + $output = (string) shell_exec("tasklist /FI \"PID eq $pid\""); return strpos($output, ' ' . $pid . ' ') !== false; } - return file_exists("/proc/$pid"); + if (function_exists('posix_kill')) { + try { + if (@posix_kill($pid, 0)) { + return true; + } + } catch (\ValueError) { + return false; + } + + // EPERM (1): the process exists but belongs to another user. + return function_exists('posix_get_last_error') && posix_get_last_error() === 1; + } + + if (is_dir('/proc/self')) { + return file_exists("/proc/$pid"); + } + + if (self::isFunctionEnabled('shell_exec')) { + return trim((string) shell_exec(sprintf('ps -p %d -o pid= 2>/dev/null', $pid))) !== ''; + } + + return false; + } + + /** + * Determine if the current platform is Windows + * + * @return bool + */ + public static function isWindows(): bool + { + return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; + } + + /** + * Determine if a PHP function can be called (it exists and is not disabled) + * + * @param string $function + * @return bool + */ + public static function isFunctionEnabled(string $function): bool + { + return function_exists($function); + } + + /** + * Get the PHP binary that child commands must run with + * + * @return string + */ + public static function phpBinary(): string + { + $binary = PHP_BINARY; + + // Under FPM/CGI/LiteSpeed PHP_BINARY is not a command line binary. + if (is_executable($binary) && !preg_match('/(fpm|cgi|lsphp)/i', basename($binary))) { + return $binary; + } + + $sibling = PHP_BINDIR . DIRECTORY_SEPARATOR . 'php'; + + return is_executable($sibling) ? $sibling : 'php'; + } + + /** + * Get the absolute path of the application's pool script + * + * @return string + */ + public static function poolScript(): string + { + return base_path('pool'); + } + + /** + * Build the argument list that runs a pool command in a child process + * + * @param string $command + * @return array + */ + public static function buildProcessArguments(string $command): array + { + return array_merge([self::phpBinary(), self::poolScript()], self::splitCommand($command)); + } + + /** + * Split a scheduled command into arguments the way a shell would + * + * @param string $command + * @return array + */ + public static function splitCommand(string $command): array + { + $command = trim($command); + $tokens = []; + $current = ''; + $inToken = false; + $quote = null; + $length = strlen($command); + + for ($i = 0; $i < $length; $i++) { + $char = $command[$i]; + + if ($quote === "'") { + if ($char === "'") { + $quote = null; + } else { + $current .= $char; + } + + continue; + } + + if ($quote === '"') { + if ($char === '\\' && $i + 1 < $length && strpos('"\\$`', $command[$i + 1]) !== false) { + $current .= $command[++$i]; + } elseif ($char === '"') { + $quote = null; + } else { + $current .= $char; + } + + continue; + } + + if ($char === "'" || $char === '"') { + $quote = $char; + $inToken = true; + + continue; + } + + if ($char === '\\' && $i + 1 < $length) { + $current .= $command[++$i]; + $inToken = true; + + continue; + } + + if (ctype_space($char)) { + if ($inToken) { + $tokens[] = $current; + $current = ''; + $inToken = false; + } + + continue; + } + + $current .= $char; + $inToken = true; + } + + if ($quote !== null) { + return preg_split('/\s+/', $command, -1, PREG_SPLIT_NO_EMPTY); + } + + if ($inToken) { + $tokens[] = $current; + } + + return $tokens; + } + + /** + * Start a shell command that backgrounds itself and prints its PID + * + * @param string $shellCommand + * @return int|null + */ + public static function startDetached(string $shellCommand): ?int + { + if (self::isFunctionEnabled('shell_exec')) { + $output = shell_exec($shellCommand); + } elseif (self::isFunctionEnabled('exec')) { + $lines = []; + exec($shellCommand, $lines); + $output = implode("\n", $lines); + } elseif (self::isFunctionEnabled('proc_open')) { + $process = Process::fromShellCommandline($shellCommand); + $process->run(); + $output = $process->getOutput(); + } else { + return null; + } + + $pid = (int) trim((string) $output); + + return $pid > 0 ? $pid : null; } } diff --git a/src/Phaseolies/Console/Schedule/ScheduledCommand.php b/src/Phaseolies/Console/Schedule/ScheduledCommand.php index 244aa15b..166bfe06 100644 --- a/src/Phaseolies/Console/Schedule/ScheduledCommand.php +++ b/src/Phaseolies/Console/Schedule/ScheduledCommand.php @@ -165,6 +165,13 @@ class ScheduledCommand */ private $lastExecutionTime = null; + /** + * Whether this instance took the overlap lock, and so is the one that may release it. + * + * @var bool + */ + private bool $ownsLock = false; + /** * Initializes the command with default lock and tracking file paths. * @@ -177,14 +184,34 @@ public function __construct(string $command) } $this->command = $command; - $tempDir = sys_get_temp_dir(); - $this->lastRunFile = $tempDir . "/doppar_cron_" . md5($this->command); - $this->lockFile = $tempDir . "/doppar_cron_lock_" . md5($this->command); + $stateDir = $this->stateDirectory(); + $this->lastRunFile = $stateDir . "/doppar_cron_" . md5($this->command); + $this->lockFile = $stateDir . "/doppar_cron_lock_" . md5($this->command); $this->ensureSecureFilePermissions($this->lastRunFile); $this->ensureSecureFilePermissions($this->lockFile); } + /** + * Get the directory that holds lock and last-run files. + * + * @return string + */ + private function stateDirectory(): string + { + try { + $directory = storage_path('schedule'); + } catch (\Throwable) { + return sys_get_temp_dir(); + } + + if (!is_dir($directory) && !@mkdir($directory, 0755, true) && !is_dir($directory)) { + return sys_get_temp_dir(); + } + + return $directory; + } + /** * Ensure secure file permissions for lock files. * @@ -1354,6 +1381,43 @@ public function isDue(bool $checkSecondSchedule = true): bool * @return bool */ private function handleOverlappingPrevention(): bool + { + return $this->withLockGuard(fn(): bool => $this->acquireOverlapLock()); + } + + /** + * Run a callback while holding an exclusive lock, so that checking for a + * running copy and taking the lock happen as one step. Without it, two + * scheduler runs that start together can both decide the command is free. + * + * @param callable $callback + * @return mixed + */ + private function withLockGuard(callable $callback): mixed + { + $handle = @fopen($this->lockFile . '.guard', 'c'); + + if ($handle === false) { + // Cannot create the guard (read-only storage): behave as before. + return $callback(); + } + + try { + flock($handle, LOCK_EX); + + return $callback(); + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } + } + + /** + * Decide whether the command may run, taking the lock if so. + * + * @return bool + */ + private function acquireOverlapLock(): bool { $lockFile = $this->getLockFile(); $pidFile = $lockFile . '.pid'; @@ -1432,13 +1496,7 @@ private function isProcessRunning(int $pid): bool return false; } - try { - $output = shell_exec(sprintf("ps -p %d -o pid=", $pid)); - - return !empty($output); - } catch (\Throwable $e) { - return false; - } + return SchedulePool::isProcessRunning($pid); } /** @@ -1464,7 +1522,15 @@ public function isLocked(): bool */ public function lock(): void { + $directory = dirname($this->lockFile); + + if (!is_dir($directory)) { + @mkdir($directory, 0755, true); + } + file_put_contents($this->lockFile, time()); + + $this->ownsLock = true; } /** @@ -1483,16 +1549,20 @@ public function releaseLock(): void if (file_exists($pidFile)) { unlink($pidFile); } + + $this->ownsLock = false; } /** - * Automatically release the lock if the command is not running in the background - * and is set to prevent overlapping executions. + * Release the lock this instance took, unless the command runs in the background */ public function __destruct() { - if ($this->withoutOverlapping) { - $this->cleanup(); + // A background job outlives this object (and the process that made it); + // its lock is released by cron:finish when the job ends. Releasing it + // here would let the next scheduler run start a second copy. + if ($this->ownsLock && $this->withoutOverlapping && !$this->runInBackground) { + $this->releaseLock(); } } } diff --git a/tests/Console/CronRunProcessTest.php b/tests/Console/CronRunProcessTest.php new file mode 100644 index 00000000..6a756992 --- /dev/null +++ b/tests/Console/CronRunProcessTest.php @@ -0,0 +1,548 @@ +originalCwd = getcwd(); + $this->originalPath = getenv('PATH'); + $this->app = new ScratchApp(); + } + + protected function tearDown(): void + { + chdir($this->originalCwd); + + // The PATH decoy test changes the process environment. + putenv('PATH=' . $this->originalPath); + $_SERVER['PATH'] = $_ENV['PATH'] = (string) $this->originalPath; + + // Let background jobs finish before their directory disappears. + ScratchApp::waitUntil(fn() => !$this->anyJobRunning(), 8); + + $this->app->destroy(); + } + + private function anyJobRunning(): bool + { + foreach (glob($this->app->path('storage/schedule/*.pid')) ?: [] as $pidFile) { + $info = json_decode((string) file_get_contents($pidFile), true); + + if (SchedulePool::isProcessRunning((int) ($info['pid'] ?? 0))) { + return true; + } + } + + return false; + } + + /** + * The overlap probe's answer (RUN or SKIP) from its stdout, ignoring any other + * text such as deprecation notices printed by third-party packages. + */ + /** + * Background execution, the lock guard and the daemon are POSIX features: they + * rely on /bin/sh, flock semantics and files that may be deleted while open. + * Windows runs background tasks in the foreground (see the docs). + */ + private function requirePosix(): void + { + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('Needs a POSIX shell; background tasks and the daemon are not supported on Windows.'); + } + } + + private function probeAnswer(string $output): string + { + foreach (array_map('trim', explode("\n", $output)) as $line) { + if ($line === 'RUN' || $line === 'SKIP') { + return $line; + } + } + + return ''; + } + + private function task(string $command): ScheduledCommand + { + return (new ScheduledCommand($command))->everyMinute()->timezone('UTC'); + } + + private function runner(ScheduledCommand ...$tasks): ScratchCronRunCommand + { + $runner = new ScratchCronRunCommand(); + $runner->scheduled = $tasks; + + return $runner; + } + + // ------------------------------------------------------------------ + // Background tasks + // ------------------------------------------------------------------ + + public function testABackgroundTaskDoesNotMakeTheSchedulerWaitForIt(): void + { + $this->requirePosix(); + + $log = $this->app->path('job.log'); + $runner = $this->runner($this->task('slow 3')->inBackground()->sendOutputTo($log)); + + $started = microtime(true); + $status = $runner->handle(); + $elapsed = microtime(true) - $started; + + $this->assertSame(Command::SUCCESS, $status); + $this->assertLessThan(1.5, $elapsed, 'cron:run must return while the 3 second job is still running'); + + $this->assertTrue( + ScratchApp::waitUntil(fn() => str_contains((string) @file_get_contents($log), 'slow done')), + 'the job still ran to completion in the background' + ); + } + + public function testTheFinishCallbackReceivesTheJobsExitCode(): void + { + $this->requirePosix(); + + $log = $this->app->path('job.log'); + $runner = $this->runner($this->task('fail')->inBackground()->sendOutputTo($log)); + + $runner->handle(); + + $this->assertTrue(ScratchApp::waitUntil(fn() => $this->app->callsTo('cron:finish') !== [])); + + $finish = $this->app->callsTo('cron:finish')[0]['args']; + + $this->assertSame('cron:finish', $finish[0]); + $this->assertSame('3', $finish[3], '$? of the failed job (3) is passed on to cron:finish'); + } + + public function testABackgroundTaskRunsFromTheAppRootWhateverDirectoryCronStartedIn(): void + { + $this->requirePosix(); + + // cPanel starts cron jobs in the account's home directory. + chdir('/'); + + $runner = $this->runner($this->task('echo hi')->inBackground()->sendOutputTo($this->app->path('job.log'))); + $status = $runner->handle(); + + $this->assertSame(Command::SUCCESS, $status); + $this->assertTrue(ScratchApp::waitUntil(fn() => $this->app->callsTo('echo') !== [])); + $this->assertSame(realpath($this->app->root), realpath($this->app->callsTo('echo')[0]['cwd'])); + } + + public function testBackgroundTasksReceiveTheScheduleEnvironmentFlag(): void + { + $this->requirePosix(); + + $runner = $this->runner($this->task('echo hi')->inBackground()->sendOutputTo($this->app->path('job.log'))); + $runner->handle(); + + $this->assertTrue(ScratchApp::waitUntil(fn() => $this->app->callsTo('echo') !== [])); + $this->assertSame('true', $this->app->callsTo('echo')[0]['flag']); + } + + public function testAProtectedBackgroundTaskIsNotStartedASecondTimeWhileItRuns(): void + { + $this->requirePosix(); + + $log = $this->app->path('job.log'); + + $first = $this->runner($this->task('slow 3')->inBackground()->noOverlap()->sendOutputTo($log)); + $first->handle(); + + // The scheduler process ends: everything it built is destroyed. The job must keep its lock. + unset($first); + gc_collect_cycles(); + + $this->assertTrue(ScratchApp::waitUntil(fn() => $this->app->callsTo('slow') !== []), 'the first job started'); + + $second = $this->runner($this->task('slow 3')->inBackground()->noOverlap()->sendOutputTo($log)); + $second->handle(); + unset($second); + gc_collect_cycles(); + + // A run that only *skipped* the task must not have released the running job's lock. + $third = $this->runner($this->task('slow 3')->inBackground()->noOverlap()->sendOutputTo($log)); + $third->handle(); + + usleep(500_000); + + $this->assertFalse($third->said('Running:'), 'no run may start another copy while the job is still running'); + $this->assertCount(1, $this->app->callsTo('slow')); + } + + public function testTheLockIsReleasedWhenTheJobFinishesSoTheTaskCanRunAgain(): void + { + $this->requirePosix(); + + $log = $this->app->path('job.log'); + $first = $this->runner($this->task('echo hi')->inBackground()->noOverlap()->sendOutputTo($log)); + $first->handle(); + unset($first); + + $this->assertTrue(ScratchApp::waitUntil(fn() => $this->app->callsTo('cron:finish') !== [])); + + // The stub pool only records cron:finish, so run the real command with the recorded arguments. + $args = $this->app->callsTo('cron:finish')[0]['args']; + $finish = new class extends CronFinishCommand { + public array $given = []; + + protected function argument($key = null) + { + return $this->given[$key] ?? null; + } + }; + $finish->given = ['finish_id' => $args[1], 'release_lock' => $args[2], 'exit_code' => $args[3]]; + + $this->assertSame(Command::SUCCESS, $finish->handle()); + + $again = $this->runner($this->task('echo hi')->inBackground()->noOverlap()->sendOutputTo($log)); + $again->handle(); + + $this->assertTrue($again->said('Running:'), 'the task runs again once its job has finished'); + } + + public function testBackgroundFallsBackToRunningInTheForegroundWhenNoProcessCanBeStarted(): void + { + $runner = $this->runner($this->task('ok')->inBackground()->noOverlap()); + $runner->canStartDetached = false; + + $status = $runner->handle(); + + $this->assertSame(Command::SUCCESS, $status); + $this->assertTrue($runner->said('running in the foreground')); + $this->assertCount(1, $this->app->callsTo('ok'), 'the task must not be lost'); + + // and its lock was released + $again = $this->runner($this->task('ok')->inBackground()->noOverlap()); + $this->assertTrue($again->handle() === Command::SUCCESS && $again->said('Running:')); + } + + // ------------------------------------------------------------------ + // Foreground tasks + // ------------------------------------------------------------------ + + public function testForegroundTasksRunWithThePhpBinaryThatIsRunningTheScheduler(): void + { + $this->requirePosix(); + + // A different "php" comes first on PATH, as on a host with several PHP versions. + $decoyDir = $this->app->path('decoy'); + mkdir($decoyDir); + file_put_contents($decoyDir . '/php', "#!/bin/sh\necho decoy >> " . escapeshellarg($this->app->path('decoy.log')) . "\nexit 1\n"); + chmod($decoyDir . '/php', 0755); + + $path = $decoyDir . ':' . getenv('PATH'); + putenv('PATH=' . $path); + $_SERVER['PATH'] = $_ENV['PATH'] = $path; + + $runner = $this->runner($this->task('ok')); + $status = $runner->handle(); + + $this->assertSame(Command::SUCCESS, $status, implode(' | ', $runner->messages)); + $this->assertFileDoesNotExist($this->app->path('decoy.log'), 'the decoy php must not be used'); + $this->assertSame(SchedulePool::phpBinary(), $this->app->callsTo('ok')[0]['php']); + } + + public function testQuotedArgumentsReachTheCommandAsOneArgument(): void + { + $runner = $this->runner($this->task('echo --message="hello world" plain \'it is\'')); + $runner->handle(); + + $this->assertSame(['echo', '--message=hello world', 'plain', 'it is'], $this->app->callsTo('echo')[0]['args']); + } + + public function testQuotedArgumentsSurviveTheBackgroundShellToo(): void + { + $this->requirePosix(); + + $runner = $this->runner( + $this->task('echo --message="hello world" --note="it\'s fine"')->inBackground()->sendOutputTo($this->app->path('job.log')) + ); + $runner->handle(); + + $this->assertTrue(ScratchApp::waitUntil(fn() => $this->app->callsTo('echo') !== [])); + $this->assertSame(['echo', '--message=hello world', "--note=it's fine"], $this->app->callsTo('echo')[0]['args']); + } + + public function testForegroundTasksReceiveTheScheduleEnvironmentFlag(): void + { + $this->runner($this->task('ok'))->handle(); + + $this->assertSame('true', $this->app->callsTo('ok')[0]['flag']); + } + + // ------------------------------------------------------------------ + // Exit status and failures + // ------------------------------------------------------------------ + + public function testARunWithNoFailuresExitsSuccessfully(): void + { + $this->assertSame(Command::SUCCESS, $this->runner($this->task('ok'))->handle()); + } + + public function testAFailingTaskMakesTheRunExitWithFailureAndShowsWhy(): void + { + $runner = $this->runner($this->task('fail')); + + $this->assertSame(Command::FAILURE, $runner->handle()); + $this->assertTrue($runner->said('exit code 3')); + $this->assertTrue($runner->said('boom')); + } + + public function testATaskThatThrowsDoesNotStopTheTasksAfterIt(): void + { + $broken = new class ('never-started') extends ScheduledCommand { + public function shouldRunInBackground(): bool + { + throw new \Error('cannot start'); + } + }; + $broken->everyMinute()->timezone('UTC'); + + $runner = $this->runner($broken, $this->task('ok')); + + $this->assertSame(Command::FAILURE, $runner->handle()); + $this->assertTrue($runner->said('cannot start')); + $this->assertCount(1, $this->app->callsTo('ok'), 'the second task still ran'); + } + + public function testAMissingScheduleClassIsReportedInsteadOfCrashing(): void + { + $runner = new ScratchCronRunCommand(); + + $this->assertSame(Command::FAILURE, $runner->handle()); + $this->assertTrue($runner->said('was not found')); + } + + public function testTasksThatAreNotDueAreLeftAlone(): void + { + $notDue = (new ScheduledCommand('ok'))->cron('0 0 31 2 *')->timezone('UTC'); // 31 Feb: never + + $runner = $this->runner($notDue); + + $this->assertSame(Command::SUCCESS, $runner->handle()); + $this->assertSame([], $this->app->calls()); + $this->assertTrue($runner->said('No scheduled commands are ready to run')); + } + + // ------------------------------------------------------------------ + // Overlap protection under a real race + // ------------------------------------------------------------------ + + public function testOnlyOneOfManySimultaneousSchedulerRunsMayStartAProtectedTask(): void + { + $this->requirePosix(); + + $processes = []; + $startAt = microtime(true) + 1.0; + + for ($i = 0; $i < 8; $i++) { + $process = proc_open( + [PHP_BINARY, __DIR__ . '/Support/overlap_probe.php', $this->app->root, (string) $startAt], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); + $processes[] = [$process, $pipes]; + } + + $answers = []; + + foreach ($processes as [$process, $pipes]) { + $answers[] = $this->probeAnswer((string) stream_get_contents($pipes[1])); + // stderr is not checked: it can carry unrelated deprecation notices printed by + // third-party packages when the autoloader loads (e.g. an old Mockery on PHP 8.5). + stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + } + + $this->assertSame(1, count(array_filter($answers, fn($a) => $a === 'RUN')), 'answers: ' . implode(',', $answers)); + $this->assertSame(7, count(array_filter($answers, fn($a) => $a === 'SKIP'))); + } + + public function testCheckingForARunningCopyWaitsForWhoeverIsCurrentlyTakingTheLock(): void + { + $this->requirePosix(); + + // Hold the guard, as a scheduler run in the middle of its check-and-lock would. + $guard = fopen((new ScheduledCommand('slow 1'))->getLockFile() . '.guard', 'c'); + $this->assertTrue(flock($guard, LOCK_EX)); + + $process = proc_open( + [PHP_BINARY, __DIR__ . '/Support/overlap_probe.php', $this->app->root, '0'], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); + stream_set_blocking($pipes[1], false); + + usleep(1_200_000); + $this->assertSame('', $this->probeAnswer((string) stream_get_contents($pipes[1])), 'the probe must be waiting for the guard, not deciding on its own'); + + flock($guard, LOCK_UN); + fclose($guard); + + stream_set_blocking($pipes[1], true); + $this->assertSame('RUN', $this->probeAnswer((string) stream_get_contents($pipes[1])), 'once the guard is free it can take the lock'); + + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + } + + public function testARunThatSkipsAProtectedTaskLeavesTheRunningTasksLockAlone(): void + { + $this->requirePosix(); + + $running = $this->task('slow 1')->noOverlap(); + $this->assertTrue($running->isDue(), 'the first run takes the lock'); + + // Later runs find it locked, skip it, and end. Ending must not release the lock. + $skipped = $this->task('slow 1')->noOverlap(); + $this->assertFalse($skipped->isDue()); + unset($skipped); + gc_collect_cycles(); + + $another = $this->task('slow 1')->noOverlap(); + $this->assertFalse($another->isDue(), 'the lock still belongs to the first run'); + + $running->releaseLock(); + $this->assertTrue($this->task('slow 1')->noOverlap()->isDue(), 'and is free once its owner releases it'); + } + + // ------------------------------------------------------------------ + // Daemon + // ------------------------------------------------------------------ + + public function testTheDaemonRefusesToStartWhileAnotherHoldsTheDaemonLock(): void + { + $this->requirePosix(); + + $lock = fopen($this->app->path('storage/schedule/cron_daemon.lock'), 'c'); + $this->assertTrue(flock($lock, LOCK_EX | LOCK_NB), 'the test holds the lock, as a running daemon would'); + + $runner = $this->runner(); + $runner->givenOptions = ['daemon' => true]; + + $this->assertSame(Command::SUCCESS, $runner->handle()); + $this->assertTrue($runner->said('already running')); + $this->assertFileDoesNotExist($this->app->path('storage/schedule/cron_daemon.pid'), 'a refused daemon must not touch the PID file'); + + fclose($lock); + } + + public function testTheDaemonLockIsFreeWhenNoDaemonRunsAndTakenOnlyOnce(): void + { + $this->requirePosix(); + + $first = new ScratchCronRunCommand(); + $second = new ScratchCronRunCommand(); + + $acquire = fn(object $runner) => (new \ReflectionMethod($runner, 'acquireDaemonLock'))->invoke($runner); + + $this->assertTrue($acquire($first)); + $this->assertFalse($acquire($second), 'a second daemon cannot take it'); + } + + // ------------------------------------------------------------------ + // Command building and the no-process fallback + // ------------------------------------------------------------------ + + public function testTheBackgroundShellCommandIsFullyDetachedAndUsesAbsolutePaths(): void + { + $this->requirePosix(); + + $runner = new ScratchCronRunCommand(); + + $shell = $runner->buildBackground('queue:run --queue=mail', '/tmp/job.log', 'cron_finish_1', true, [ + 'APP_SCHEDULE_RUNNING' => 'true', + 'BAD-NAME' => 'x', + ]); + + $this->assertStringContainsString("cd '" . $this->app->root . "'", $shell); + $this->assertStringContainsString("'" . SchedulePool::phpBinary() . "' '" . $this->app->root . "/pool'", $shell); + $this->assertStringContainsString("APP_SCHEDULE_RUNNING='true'", $shell); + $this->assertStringNotContainsString('BAD-NAME', $shell, 'invalid variable names are dropped'); + $this->assertStringContainsString("'queue:run' '--queue=mail'", $shell); + $this->assertStringContainsString("cron:finish 'cron_finish_1' 1 \$?", $shell); + $this->assertStringEndsWith('> /dev/null 2>&1 < /dev/null & echo $!', $shell, 'must not hold the caller\'s output pipe'); + } + + public function testShellSpecialCharactersInArgumentsCannotBreakOutOfTheCommand(): void + { + $this->requirePosix(); + + $runner = new ScratchCronRunCommand(); + + $shell = $runner->buildBackground('demo --x="a; touch /tmp/pwned" $(id) `id`', '/tmp/job.log', 'id', false); + + $this->assertStringContainsString("'--x=a; touch /tmp/pwned'", $shell); + $this->assertStringContainsString("'\$(id)'", $shell); + $this->assertStringContainsString("'`id`'", $shell); + } + + public function testACommandCanRunInsideTheSchedulerProcessWhenNothingCanStartAProcess(): void + { + $application = new Application(); + $register = method_exists($application, 'addCommand') ? 'addCommand' : 'add'; + $application->{$register}(new class ('demo') extends SymfonyCommand { + protected function configure(): void + { + $this->ignoreValidationErrors(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $output->writeln('args: ' . implode('|', array_slice($_SERVER['argv'] ?? [], 0))); + $output->writeln('message: ' . (string) $input->getParameterOption('--message')); + + return 4; + } + }); + + $runner = new ScratchCronRunCommand(); + $runner->setApplication($application); + + $result = $runner->runProcessInProcess('demo --message="hello world"'); + + $this->assertSame(4, $result['code']); + $this->assertStringContainsString('message: hello world', $result['stdout']); + } + + public function testRunningInProcessWithoutAConsoleApplicationIsAClearError(): void + { + $runner = new ScratchCronRunCommand(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('proc_open, exec and shell_exec are disabled'); + + $runner->runProcessInProcess('ok'); + } +} diff --git a/tests/Console/SchedulePoolProcessTest.php b/tests/Console/SchedulePoolProcessTest.php new file mode 100644 index 00000000..3f6e0e0c --- /dev/null +++ b/tests/Console/SchedulePoolProcessTest.php @@ -0,0 +1,81 @@ +app = new ScratchApp(); + } + + protected function tearDown(): void + { + $this->app->destroy(); + } + + public function testProcessArgumentsUseTheRunningPhpAndTheAbsolutePoolScript(): void + { + $arguments = SchedulePool::buildProcessArguments('queue:run --queue="high priority"'); + + $this->assertSame([ + SchedulePool::phpBinary(), + $this->app->root . DIRECTORY_SEPARATOR . 'pool', + 'queue:run', + '--queue=high priority', + ], $arguments); + } + + public function testTheCurrentProcessIsRunning(): void + { + $this->assertTrue(SchedulePool::isProcessRunning(getmypid())); + } + + public function testNonPositiveAndUnusedPidsAreNotRunning(): void + { + $this->assertFalse(SchedulePool::isProcessRunning(0)); + $this->assertFalse(SchedulePool::isProcessRunning(-5)); + $this->assertFalse(SchedulePool::isProcessRunning(PHP_INT_MAX)); + } + + public function testAProcessThatHasEndedIsNotRunning(): void + { + $process = proc_open([PHP_BINARY, '-r', 'exit(0);'], [], $pipes); + $pid = proc_get_status($process)['pid']; + proc_close($process); + + $this->assertFalse(SchedulePool::isProcessRunning($pid)); + } + + public function testADetachedCommandReturnsItsPidWithoutWaitingForIt(): void + { + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('Uses POSIX shell syntax (background & and $!).'); + } + + $started = microtime(true); + $pid = SchedulePool::startDetached('(sleep 2) > /dev/null 2>&1 < /dev/null & echo $!'); + $elapsed = microtime(true) - $started; + + $this->assertNotNull($pid); + $this->assertLessThan(1.0, $elapsed, 'must not wait for the two second command'); + $this->assertTrue(SchedulePool::isProcessRunning($pid)); + + // SIGKILL (9); the constant needs pcntl and posix may be absent, so use kill. + exec('kill -9 ' . (int) $pid); + } + + public function testANonNumericResultIsNotAPid(): void + { + $this->assertNull(SchedulePool::startDetached('echo not-a-pid')); + } +} diff --git a/tests/Console/SchedulePoolTest.php b/tests/Console/SchedulePoolTest.php index 8a89cdb3..3b099a8a 100644 --- a/tests/Console/SchedulePoolTest.php +++ b/tests/Console/SchedulePoolTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\Console; use Phaseolies\Console\Schedule\SchedulePool; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class SchedulePoolTest extends TestCase @@ -53,4 +54,49 @@ public function testOverridesAreIncludedAndStringified(): void $this->assertSame('true', $env['APP_RUNNING_IN_CONSOLE']); $this->assertSame('true', $env['APP_SCHEDULE_RUNNING']); } + + /** + * @return array}> + */ + public static function commandProvider(): array + { + return [ + 'plain arguments' => ['queue:run --queue=high --sleep=3', ['queue:run', '--queue=high', '--sleep=3']], + 'extra whitespace' => [" queue:run --queue=high \t --sleep=3 ", ['queue:run', '--queue=high', '--sleep=3']], + 'double quotes group words' => ['mail:send --subject="Hello world"', ['mail:send', '--subject=Hello world']], + 'single quotes group words' => ["mail:send --subject='Hello world'", ['mail:send', '--subject=Hello world']], + 'quote in the middle of a word' => ['run --name=Jo"hn Smith"', ['run', '--name=John Smith']], + 'apostrophe inside double quotes' => ['run --note="it\'s fine"', ['run', "--note=it's fine"]], + 'escaped quote inside double quotes' => ['run --x="a \\"b\\" c"', ['run', '--x=a "b" c']], + 'escaped space' => ['run path\\ with\\ spaces', ['run', 'path with spaces']], + 'empty quoted argument is kept' => ['run "" last', ['run', '', 'last']], + 'dollar and backtick stay literal' => ['run $HOME `id`', ['run', '$HOME', '`id`']], + 'single argument' => ['cron:list', ['cron:list']], + 'unbalanced quote falls back to whitespace' => ['run --note=it\'s ok', ['run', '--note=it\'s', 'ok']], + 'unbalanced double quote falls back to whitespace' => ['run "oops here', ['run', '"oops', 'here']], + ]; + } + + /** + * @param array $expected + */ + #[DataProvider('commandProvider')] + public function testSplitCommandTokenizesLikeAShell(string $command, array $expected): void + { + $this->assertSame($expected, SchedulePool::splitCommand($command)); + } + + public function testFunctionAvailabilityReflectsWhatCanBeCalled(): void + { + $this->assertTrue(SchedulePool::isFunctionEnabled('strlen')); + $this->assertFalse(SchedulePool::isFunctionEnabled('a_function_that_does_not_exist')); + } + + public function testThePhpBinaryIsAnExecutablePath(): void + { + $binary = SchedulePool::phpBinary(); + + $this->assertTrue($binary === 'php' || is_executable($binary), $binary); + $this->assertDoesNotMatchRegularExpression('/(fpm|cgi|lsphp)/i', basename($binary)); + } } diff --git a/tests/Console/ScheduledCommandTest.php b/tests/Console/ScheduledCommandTest.php index 7d5289b3..e0e005a2 100644 --- a/tests/Console/ScheduledCommandTest.php +++ b/tests/Console/ScheduledCommandTest.php @@ -6,6 +6,14 @@ final class ScheduledCommandTestEnvironment { + /** + * The function stubs below replace real functions for the whole PHPUnit + * process once this file is loaded. They only act while a test of this class + * is running; otherwise they call the real function, so tests elsewhere + * (real processes, real time, real posix_kill) are not affected. + */ + public static bool $active = false; + public static Carbon $now; public static string $timezone = 'UTC'; @@ -22,6 +30,7 @@ final class ScheduledCommandTestEnvironment public static function reset(): void { + self::$active = true; self::$storageRoot = sys_get_temp_dir() . '/doppar-schedule-tests'; self::purgeDirectory(self::$storageRoot); @@ -39,6 +48,7 @@ public static function reset(): void public static function cleanup(): void { + self::$active = false; Carbon::setTestNow(); self::purgeDirectory(self::$storageRoot); self::$execQueue = []; @@ -125,34 +135,58 @@ private static function purgeDirectory(string $directory): void function config($key = null, $default = null) { + if (!ScheduledCommandTestEnvironment::$active) { + return \config($key, $default); + } + return $key === 'app.timezone' ? ScheduledCommandTestEnvironment::$timezone : $default; } function now($timezone = null) { + if (!ScheduledCommandTestEnvironment::$active) { + return \now($timezone); + } + return ScheduledCommandTestEnvironment::now($timezone); } function storage_path($path = '') { + if (!ScheduledCommandTestEnvironment::$active) { + return \storage_path($path); + } + return ScheduledCommandTestEnvironment::storagePath($path); } function time() { + if (!ScheduledCommandTestEnvironment::$active) { + return \time(); + } + return ScheduledCommandTestEnvironment::timestamp(); } function sleep(int $seconds) { + if (!ScheduledCommandTestEnvironment::$active) { + return \sleep($seconds); + } + ScheduledCommandTestEnvironment::$sleepCalls[] = $seconds; ScheduledCommandTestEnvironment::advanceSeconds($seconds); return 0; } -function shell_exec(string $command): string +function shell_exec(string $command): ?string { + if (!ScheduledCommandTestEnvironment::$active) { + return \shell_exec($command); + } + if (preg_match('/ps -p (\d+) -o pid=/', $command, $matches)) { $pid = (int) $matches[1]; @@ -161,11 +195,43 @@ function shell_exec(string $command): string : ''; } + // Windows: SchedulePool::isProcessRunning() asks tasklist instead of ps. + if (preg_match('/tasklist \/FI "PID eq (\d+)"/', $command, $matches)) { + $pid = (int) $matches[1]; + + return in_array($pid, ScheduledCommandTestEnvironment::$runningPids, true) + ? 'php.exe ' . $pid . ' Console 1 20,000 K' . PHP_EOL + : 'INFO: No tasks are running which match the specified criteria.' . PHP_EOL; + } + return ''; } -function exec(string $command, &$output = null, &$returnVar = null): ?string +function posix_kill(int $process_id, int $signal): bool +{ + if (!ScheduledCommandTestEnvironment::$active) { + return \posix_kill($process_id, $signal); + } + + return in_array($process_id, ScheduledCommandTestEnvironment::$runningPids, true); +} + +function posix_get_last_error(): int { + if (!ScheduledCommandTestEnvironment::$active) { + return \posix_get_last_error(); + } + + // ESRCH: no such process + return 3; +} + +function exec(string $command, &$output = null, &$returnVar = null): string|false|null +{ + if (!ScheduledCommandTestEnvironment::$active) { + return \exec($command, $output, $returnVar); + } + ScheduledCommandTestEnvironment::$execCalls[] = $command; $response = array_shift(ScheduledCommandTestEnvironment::$execQueue) ?? [ @@ -664,12 +730,12 @@ private function newScheduledCommand(string $suffix): ScheduledCommand private function lastRunFile(string $command): string { - return sys_get_temp_dir() . '/doppar_cron_' . md5($command); + return Env::storagePath('schedule') . '/doppar_cron_' . md5($command); } private function lockFile(string $command): string { - return sys_get_temp_dir() . '/doppar_cron_lock_' . md5($command); + return Env::storagePath('schedule') . '/doppar_cron_lock_' . md5($command); } private function throttleFile(string $command): string diff --git a/tests/Console/Support/CommandTestEnvironment.php b/tests/Console/Support/CommandTestEnvironment.php index b049ac64..fe0658e3 100644 --- a/tests/Console/Support/CommandTestEnvironment.php +++ b/tests/Console/Support/CommandTestEnvironment.php @@ -4,6 +4,15 @@ final class CommandTestEnvironment { + /** + * Some stubs below live in namespaces that production scheduler code also + * uses (Phaseolies\Console\Schedule, Phaseolies\Console\Commands\Cron). + * They stay defined for the whole PHPUnit process, so they only act while a + * test has called reset() and not yet cleanup(); otherwise they defer to the + * real helper and other tests are not affected. + */ + public static bool $active = false; + public static string $root; public static array $config = []; @@ -16,6 +25,7 @@ final class CommandTestEnvironment public static function reset(): void { + self::$active = true; self::$root = rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . 'doppar-command-tests-' @@ -36,6 +46,8 @@ public static function reset(): void public static function cleanup(): void { + self::$active = false; + if (!isset(self::$root) || !is_dir(self::$root)) { return; } @@ -388,6 +400,10 @@ function app(?string $key = null): mixed if (!function_exists(__NAMESPACE__ . '\base_path')) { function base_path(string $path = ''): string { + if (!CommandTestEnvironment::$active) { + return \base_path($path); + } + return CommandTestEnvironment::path($path); } } @@ -400,6 +416,10 @@ function base_path(string $path = ''): string if (!function_exists(__NAMESPACE__ . '\storage_path')) { function storage_path(string $path = ''): string { + if (!CommandTestEnvironment::$active) { + return \storage_path($path); + } + return CommandTestEnvironment::path('storage/' . ltrim($path, '/')); } } @@ -407,6 +427,12 @@ function storage_path(string $path = ''): string if (!function_exists(__NAMESPACE__ . '\error')) { function error(string $message): void { + if (!CommandTestEnvironment::$active) { + \error($message); + + return; + } + CommandTestEnvironment::recordError($message); } } diff --git a/tests/Console/Support/ScratchApp.php b/tests/Console/Support/ScratchApp.php new file mode 100644 index 00000000..f8d40613 --- /dev/null +++ b/tests/Console/Support/ScratchApp.php @@ -0,0 +1,127 @@ + sleeps, then exits 0 + * fail prints to stderr and exits 3 + * ok prints "ok" + * echo ... prints its arguments as JSON + * Anything else, including cron:finish, is only recorded. + */ +final class ScratchApp +{ + public string $root; + + public function __construct() + { + $this->root = rtrim(sys_get_temp_dir(), '/') . '/doppar-cron-app-' . bin2hex(random_bytes(5)); + + mkdir($this->root . '/storage/schedule', 0755, true); + + file_put_contents($this->root . '/pool', <<<'PHP' + getcwd(), + 'php' => PHP_BINARY, + 'args' => $args, + 'flag' => getenv('APP_SCHEDULE_RUNNING'), +]) . "\n", FILE_APPEND | LOCK_EX); + +switch ($args[0] ?? '') { + case 'slow': + sleep((int) ($args[1] ?? 2)); + echo "slow done\n"; + exit(0); + case 'fail': + fwrite(STDERR, "boom\n"); + exit(3); + case 'ok': + echo "ok\n"; + exit(0); + case 'echo': + echo json_encode(array_slice($args, 1)), "\n"; + exit(0); +} +PHP); + + Container::setInstance((new ScratchAppContainer())->setRoot($this->root)); + } + + public function path(string $path = ''): string + { + return $path === '' ? $this->root : $this->root . '/' . ltrim($path, '/'); + } + + /** + * @return array, flag: string|false}> + */ + public function calls(): array + { + $file = $this->path('calls.log'); + + if (!file_exists($file)) { + return []; + } + + return array_map( + fn(string $line) => json_decode($line, true), + array_values(array_filter(explode("\n", (string) file_get_contents($file)))) + ); + } + + /** + * Calls whose first argument is the given command name. + * + * @return array> + */ + public function callsTo(string $command): array + { + return array_values(array_filter($this->calls(), fn(array $call) => ($call['args'][0] ?? null) === $command)); + } + + public function destroy(): void + { + Container::forgetInstance(); + + if (!is_dir($this->root)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->root, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $item) { + $item->isDir() && !$item->isLink() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + + rmdir($this->root); + } + + /** + * Wait until a condition holds, or fail after the timeout. + */ + public static function waitUntil(callable $condition, float $timeoutSeconds = 10.0): bool + { + $deadline = microtime(true) + $timeoutSeconds; + + while (microtime(true) < $deadline) { + if ($condition()) { + return true; + } + + usleep(50_000); + } + + return (bool) $condition(); + } +} diff --git a/tests/Console/Support/ScratchAppContainer.php b/tests/Console/Support/ScratchAppContainer.php new file mode 100644 index 00000000..85f19765 --- /dev/null +++ b/tests/Console/Support/ScratchAppContainer.php @@ -0,0 +1,29 @@ +root = $root; + $this->basePath = $root; + + return $this; + } + + public function storagePath(string $path = ''): string + { + $base = $this->root . DIRECTORY_SEPARATOR . 'storage'; + + return $path === '' ? $base : $base . DIRECTORY_SEPARATOR . ltrim($path, '/\\'); + } +} diff --git a/tests/Console/Support/ScratchCronRunCommand.php b/tests/Console/Support/ScratchCronRunCommand.php new file mode 100644 index 00000000..e531fd61 --- /dev/null +++ b/tests/Console/Support/ScratchCronRunCommand.php @@ -0,0 +1,117 @@ + */ + public array $scheduled = []; + + /** @var array */ + public array $givenOptions = []; + + /** @var array */ + public array $messages = []; + + public bool $canStartDetached = true; + + protected function option($key = null) + { + return $key === null ? $this->givenOptions : ($this->givenOptions[$key] ?? null); + } + + protected function makeSchedule(): ?object + { + if ($this->scheduled === [] && !class_exists('App\\Schedule\\Schedule')) { + return parent::makeSchedule(); + } + + return new class ($this->scheduled) { + public function __construct(private array $commands) + { + } + + public function getCommands(): array + { + return $this->commands; + } + }; + } + + protected function startDetached(string $shellCommand): ?int + { + return $this->canStartDetached ? parent::startDetached($shellCommand) : null; + } + + protected function executeWithTiming(callable $callback): int + { + return $callback(); + } + + protected function info($string): void + { + $this->messages[] = "info: {$string}"; + } + + protected function line(string $string, ?string $style = null): void + { + $this->messages[] = "line: {$string}"; + } + + protected function error($string): void + { + $this->messages[] = "error: {$string}"; + } + + protected function newLine($count = 1): void + { + } + + protected function displayInfo(string $message): void + { + $this->messages[] = "info: {$message}"; + } + + protected function displayWarning(string $message): void + { + $this->messages[] = "warn: {$message}"; + } + + protected function displayError(string $message): void + { + $this->messages[] = "error: {$message}"; + } + + protected function displaySuccess(string $message): void + { + $this->messages[] = "ok: {$message}"; + } + + public function said(string $needle): bool + { + foreach ($this->messages as $message) { + if (str_contains($message, $needle)) { + return true; + } + } + + return false; + } + + public function buildBackground(string $command, string $log, string $finishId, bool $release, array $env = []): string + { + return $this->buildBackgroundCommand($command, $log, $finishId, $release, $env); + } + + public function runProcessInProcess(string $command): array + { + return $this->runInProcess($command); + } +} diff --git a/tests/Console/Support/overlap_probe.php b/tests/Console/Support/overlap_probe.php new file mode 100644 index 00000000..f80bffeb --- /dev/null +++ b/tests/Console/Support/overlap_probe.php @@ -0,0 +1,36 @@ + + */ + +// Third-party packages can print deprecation notices when the autoloader loads +// them; they are noise for this probe, whose only output is RUN or SKIP. +error_reporting(E_ALL & ~E_DEPRECATED); + +require __DIR__ . '/../../../vendor/autoload.php'; + +use Phaseolies\Console\Schedule\ScheduledCommand; +use Phaseolies\DI\Container; +use Tests\Console\Support\ScratchAppContainer; + +Container::setInstance((new ScratchAppContainer())->setRoot($argv[1])); + +$task = (new ScheduledCommand('slow 1'))->everyMinute()->timezone('UTC')->noOverlap(); + +// All probes ask at the same instant, to force the race. +while (microtime(true) < (float) $argv[2]) { + usleep(100); +} + +$due = $task->isDue(); + +echo $due ? "RUN\n" : "SKIP\n"; + +if ($due) { + // Hold the lock for a while, as a running job would. + sleep(3); +}