From fbf036598ac2d28df01910f84f407dd994ca882c Mon Sep 17 00:00:00 2001 From: DariusIII Date: Tue, 4 Aug 2026 11:51:35 +0200 Subject: [PATCH] Update to latest tmux pane handling --- app/Console/Commands/TmuxAttach.php | 20 +- app/Console/Commands/TmuxHealthCheck.php | 20 +- app/Console/Commands/TmuxMonitor.php | 4 +- app/Console/Commands/TmuxStart.php | 31 +- app/Enums/TmuxPaneRole.php | 29 ++ app/Services/Tmux/TmuxLayoutBuilder.php | 349 ++++++++++++------ app/Services/Tmux/TmuxPaneManager.php | 224 ++++++++--- app/Services/Tmux/TmuxSessionManager.php | 76 +++- app/Services/Tmux/TmuxTaskRunner.php | 36 +- .../Console/TmuxHealthCheckCommandTest.php | 50 ++- tests/Feature/TmuxPaneManagementTest.php | 297 +++++++++++++++ 11 files changed, 921 insertions(+), 215 deletions(-) create mode 100644 app/Enums/TmuxPaneRole.php create mode 100644 tests/Feature/TmuxPaneManagementTest.php diff --git a/app/Console/Commands/TmuxAttach.php b/app/Console/Commands/TmuxAttach.php index 29221aec8..809846f07 100644 --- a/app/Console/Commands/TmuxAttach.php +++ b/app/Console/Commands/TmuxAttach.php @@ -4,10 +4,12 @@ declare(strict_types=1); namespace App\Console\Commands; +use App\Enums\TmuxPaneRole; use App\Models\Settings; use App\Services\Tmux\TmuxPaneManager; use App\Services\Tmux\TmuxSessionManager; use Illuminate\Console\Command; +use RuntimeException; class TmuxAttach extends Command { @@ -49,12 +51,20 @@ class TmuxAttach extends Command // Select monitor pane before attaching so user lands there $paneManager = new TmuxPaneManager($sessionName); - $paneManager->selectWindow(0); - $paneManager->selectPane('0.0'); + try { + $monitorPane = $paneManager->paneForRole(TmuxPaneRole::Monitor, '0.0'); + } catch (RuntimeException $exception) { + $this->error('❌ '.$exception->getMessage()); - // Execute tmux attach - passthru("tmux attach -t {$sessionName}"); + return Command::FAILURE; + } - return Command::SUCCESS; + if (! $paneManager->selectWindow(0) || ! $paneManager->selectPane($monitorPane)) { + $this->error('❌ Unable to select the tmux monitor pane.'); + + return Command::FAILURE; + } + + return $sessionManager->attachSession() ? Command::SUCCESS : Command::FAILURE; } } diff --git a/app/Console/Commands/TmuxHealthCheck.php b/app/Console/Commands/TmuxHealthCheck.php index 82f4ed47b..f117b0d22 100644 --- a/app/Console/Commands/TmuxHealthCheck.php +++ b/app/Console/Commands/TmuxHealthCheck.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace App\Console\Commands; +use App\Enums\TmuxPaneRole; use App\Models\Settings; +use App\Services\Tmux\TmuxPaneManager; use App\Services\Tmux\TmuxSessionManager; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; @@ -77,7 +79,7 @@ class TmuxHealthCheck extends Command if ($monitorPaneDead) { if (! $quiet) { - $this->warn('⚠️ Monitor pane (0.0) is dead.'); + $this->warn('⚠️ Monitor pane is dead.'); } if ($autoRestart) { @@ -92,7 +94,7 @@ class TmuxHealthCheck extends Command } if (! $quiet) { - $this->info('✅ Monitor pane (0.0) is alive and running.'); + $this->info('✅ Monitor pane is alive and running.'); } $this->logHealthCheck('info', 'healthy'); @@ -111,12 +113,20 @@ class TmuxHealthCheck extends Command } /** - * Check if the monitor pane (0.0) is dead. + * Check if the monitor pane is dead. */ private function isMonitorPaneDead(): bool { + $paneManager = new TmuxPaneManager($this->sessionName); + + try { + $monitorPane = $paneManager->paneForRole(TmuxPaneRole::Monitor, '0.0'); + } catch (\RuntimeException) { + return true; + } + $result = Process::timeout(10)->run( - "tmux display-message -p -t {$this->sessionName}:0.0 '#{pane_dead}'" + ['tmux', 'display-message', '-p', '-t', $monitorPane, '#{pane_dead}'] ); if (! $result->successful()) { @@ -130,7 +140,7 @@ class TmuxHealthCheck extends Command } $commandResult = Process::timeout(10)->run( - "tmux display-message -p -t {$this->sessionName}:0.0 '#{pane_current_command}'" + ['tmux', 'display-message', '-p', '-t', $monitorPane, '#{pane_current_command}'] ); if (! $commandResult->successful()) { diff --git a/app/Console/Commands/TmuxMonitor.php b/app/Console/Commands/TmuxMonitor.php index ed476480a..7d5bb03b9 100644 --- a/app/Console/Commands/TmuxMonitor.php +++ b/app/Console/Commands/TmuxMonitor.php @@ -193,7 +193,7 @@ class TmuxMonitor extends Command $this->taskRunner->runBackfill($runVar); // Update releases - $this->taskRunner->runReleasesUpdate(array_merge($runVar, ['pane' => '0.3'])); + $this->taskRunner->runReleasesUpdate($runVar); // Post-processing and cleanup tasks $this->runPostProcessingTasks($runVar); @@ -207,7 +207,7 @@ class TmuxMonitor extends Command private function runBasicTasks(array $runVar): void { // Update releases - $this->taskRunner->runReleasesUpdate(array_merge($runVar, ['pane' => '0.1'])); + $this->taskRunner->runReleasesUpdate($runVar); // Post-processing and cleanup tasks $this->runPostProcessingTasks($runVar); diff --git a/app/Console/Commands/TmuxStart.php b/app/Console/Commands/TmuxStart.php index a7f5ac2b6..d9cd44163 100644 --- a/app/Console/Commands/TmuxStart.php +++ b/app/Console/Commands/TmuxStart.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Console\Commands; +use App\Enums\TmuxPaneRole; use App\Models\Collection; use App\Models\Settings; use App\Services\Tmux\TmuxLayoutBuilder; @@ -36,6 +37,8 @@ class TmuxStart extends Command private TmuxLayoutBuilder $layoutBuilder; + private bool $sessionCreated = false; + /** * Execute the console command. */ @@ -86,12 +89,13 @@ class TmuxStart extends Command // Build the tmux layout if (! $this->layoutBuilder->buildLayout($sequential)) { - $this->error('❌ Failed to build tmux layout'); + $this->error('❌ Failed to build tmux layout: '.($this->layoutBuilder->lastError() ?? 'unknown error')); return Command::FAILURE; } $this->info('✅ Tmux layout created'); + $this->sessionCreated = true; // Set running flag Settings::query()->where('name', 'running')->update(['value' => 1]); @@ -101,17 +105,23 @@ class TmuxStart extends Command $this->info('🚀 Starting monitor...'); $this->startMonitor($sessionName); - // Select monitor pane (0.0) so attach lands there + // Select monitor pane so attach lands there $paneManager = new TmuxPaneManager($sessionName); - $paneManager->selectWindow(0); - $paneManager->selectPane('0.0'); + if (! $paneManager->selectWindow(0) + || ! $paneManager->selectPane($paneManager->paneForRole(TmuxPaneRole::Monitor, '0.0'))) { + throw new \RuntimeException($paneManager->lastError() ?? 'Unable to select the tmux monitor pane.'); + } $this->info("✅ Tmux session '{$sessionName}' started successfully"); // Attach if requested if ($this->option('attach')) { $this->info('📎 Attaching to session...'); - $this->sessionManager->attachSession(); + if (! $this->sessionManager->attachSession()) { + $this->error('❌ Unable to attach to the tmux session.'); + + return Command::FAILURE; + } } else { $this->info("💡 To attach to the session, run: tmux attach -t {$sessionName}"); $this->info('💡 Or use: php artisan tmux:attach'); @@ -120,6 +130,11 @@ class TmuxStart extends Command return Command::SUCCESS; } catch (\Exception $e) { + if ($this->sessionCreated && isset($this->sessionManager)) { + $this->sessionManager->killSession(); + Settings::query()->where('name', 'running')->update(['value' => 0]); + } + $this->error('❌ Failed to start tmux: '.$e->getMessage()); logger()->error('Tmux start error', [ 'message' => $e->getMessage(), @@ -192,7 +207,9 @@ class TmuxStart extends Command $this->warn(' ⚠ Using artisan command (not recommended for pane)'); } - // Spawn monitor in pane 0.0 - $paneManager->respawnPane('0.0', $command); + $monitorPane = $paneManager->paneForRole(TmuxPaneRole::Monitor, '0.0'); + if (! $paneManager->respawnPane($monitorPane, $command)) { + throw new \RuntimeException('Unable to start the tmux monitor pane.'); + } } } diff --git a/app/Enums/TmuxPaneRole.php b/app/Enums/TmuxPaneRole.php new file mode 100644 index 000000000..da13b4262 --- /dev/null +++ b/app/Enums/TmuxPaneRole.php @@ -0,0 +1,29 @@ +sessionManager = $sessionManager; - $this->sessionName = $sessionManager->getSessionName(); + $this->sessionName = $sessionManager->sessionName(); $this->paneManager = new TmuxPaneManager($this->sessionName); } @@ -78,55 +85,90 @@ class TmuxLayoutBuilder */ public function buildLayout(int $sequentialMode): bool { - return match ($sequentialMode) { - 1 => $this->buildBasicLayout(), - 2 => $this->buildStrippedLayout(), - default => $this->buildFullLayout(), - }; + try { + match ($sequentialMode) { + 1 => $this->buildBasicLayout(), + 2 => $this->buildStrippedLayout(), + default => $this->buildFullLayout(), + }; + + return true; + } catch (Throwable $exception) { + $this->lastError = $exception->getMessage(); + + if ($this->sessionCreated) { + $this->sessionManager->killSession(); + } + + return false; + } } /** * Build full non-sequential layout (mode 0) */ - protected function buildFullLayout(): bool + protected function buildFullLayout(): void { // Window 0: Monitor + Binaries + Backfill + Releases - if (! $this->sessionManager->createSession($this->getPaneDisplayName('Monitor'))) { - return false; - } - - // Select pane 0.0, then split for binaries (right side, 67%) - $this->paneManager->selectPane('0.0'); - $this->paneManager->splitHorizontal('0', '67%', $this->getPaneDisplayName('update_binaries')); - - // Select pane 0.1 (binaries), then split for backfill (bottom, 67%) - $this->paneManager->selectPane('0.1'); - $this->paneManager->splitVertical('0', '67%', $this->getPaneDisplayName('backfill')); - - // Split again for releases (bottom, 50%) - $this->paneManager->splitVertical('0', '50%', $this->getPaneDisplayName('update_releases')); + $monitor = $this->createSessionPane(TmuxPaneRole::Monitor, $this->getPaneDisplayName('Monitor')); + $binaries = $this->splitHorizontal( + $monitor, + 67, + TmuxPaneRole::Binaries, + $this->getPaneDisplayName('update_binaries'), + ); + $backfill = $this->splitVertical( + $binaries, + 67, + TmuxPaneRole::Backfill, + $this->getPaneDisplayName('backfill'), + ); + $this->splitVertical( + $backfill, + 50, + TmuxPaneRole::Releases, + $this->getPaneDisplayName('update_releases'), + ); // Window 1: Fix names + Remove crap - $this->paneManager->createWindow(1, ' Utils'); - $this->paneManager->setPaneTitle('1.0', $this->getPaneDisplayName('fixReleaseNames')); - $this->paneManager->selectPane('1.0'); - $this->paneManager->splitHorizontal('1', '50%', $this->getPaneDisplayName('removeCrapReleases')); + $fixNames = $this->createWindowPane( + 1, + ' Utils', + TmuxPaneRole::FixNames, + $this->getPaneDisplayName('fixReleaseNames'), + ); + $this->splitHorizontal( + $fixNames, + 50, + TmuxPaneRole::RemoveCrap, + $this->getPaneDisplayName('removeCrapReleases'), + ); - // Window 2: Postprocessing (Left: Additional + TV + Metadata, Right: Movies + XXX) - $this->paneManager->createWindow(2, ' Post'); - $this->paneManager->setPaneTitle('2.0', $this->getPaneDisplayName('postprocessing_additional')); - - // Split horizontally to create left and right halves (don't set title yet) - $this->paneManager->splitHorizontal('2', '50%', ''); - - // Left side (2.0): split vertically for TV and Metadata - $this->paneManager->selectPane('2.0'); - $this->paneManager->splitVertical('2', '67%', $this->getPaneDisplayName('postprocessing_tv')); - $this->paneManager->splitVertical('2', '50%', $this->getPaneDisplayName('postprocessing_amazon')); - - // Right side: After left splits, right side is now 2.3 - $this->paneManager->selectPane('2.3'); - $this->paneManager->setPaneTitle('2.3', $this->getPaneDisplayName('postprocessing_movies')); + // Window 2: Postprocessing (left stack and right-side movies) + $additional = $this->createWindowPane( + 2, + ' Post', + TmuxPaneRole::PostAdditional, + $this->getPaneDisplayName('postprocessing_additional'), + ); + $this->splitHorizontal( + $additional, + 50, + TmuxPaneRole::PostMovies, + $this->getPaneDisplayName('postprocessing_movies'), + ); + $tv = $this->splitVertical( + $additional, + 67, + TmuxPaneRole::PostTv, + $this->getPaneDisplayName('postprocessing_tv'), + ); + $this->splitVertical( + $tv, + 50, + TmuxPaneRole::PostMetadata, + $this->getPaneDisplayName('postprocessing_amazon'), + ); // Window 3: IRC Scraper $this->createIRCScraperWindow(); @@ -134,90 +176,115 @@ class TmuxLayoutBuilder // Additional windows (optional monitoring tools) $this->createOptionalWindows(); - return true; } /** * Build basic sequential layout (mode 1) */ - protected function buildBasicLayout(): bool + protected function buildBasicLayout(): void { // Window 0: Monitor + Releases - if (! $this->sessionManager->createSession($this->getPaneDisplayName('Monitor'))) { - return false; - } - - // Select pane 0.0, then split for releases - $this->paneManager->selectPane('0.0'); - $this->paneManager->splitHorizontal('0', '67%', $this->getPaneDisplayName('update_releases')); + $monitor = $this->createSessionPane(TmuxPaneRole::Monitor, $this->getPaneDisplayName('Monitor')); + $this->splitHorizontal( + $monitor, + 67, + TmuxPaneRole::Releases, + $this->getPaneDisplayName('update_releases'), + ); // Window 1: Utils (Fix names + Remove crap) - $this->paneManager->createWindow(1, ' Utils'); - $this->paneManager->setPaneTitle('1.0', $this->getPaneDisplayName('fixReleaseNames')); - $this->paneManager->selectPane('1.0'); - $this->paneManager->splitHorizontal('1', '50%', $this->getPaneDisplayName('removeCrapReleases')); + $fixNames = $this->createWindowPane( + 1, + ' Utils', + TmuxPaneRole::FixNames, + $this->getPaneDisplayName('fixReleaseNames'), + ); + $this->splitHorizontal( + $fixNames, + 50, + TmuxPaneRole::RemoveCrap, + $this->getPaneDisplayName('removeCrapReleases'), + ); - // Window 2: Postprocessing (Left: Additional + TV + Metadata, Right: Movies + XXX) - $this->paneManager->createWindow(2, ' Post'); - $this->paneManager->setPaneTitle('2.0', $this->getPaneDisplayName('postprocessing_additional')); - - // Split horizontally to create left and right halves (don't set title yet) - $this->paneManager->splitHorizontal('2', '50%', ''); - - // Left side (2.0): split vertically for TV and Metadata - $this->paneManager->selectPane('2.0'); - $this->paneManager->splitVertical('2', '67%', $this->getPaneDisplayName('postprocessing_tv')); - $this->paneManager->splitVertical('2', '50%', $this->getPaneDisplayName('postprocessing_amazon')); - - // Right side: After left splits, right side is now 2.3 - $this->paneManager->selectPane('2.3'); - $this->paneManager->setPaneTitle('2.3', $this->getPaneDisplayName('postprocessing_movies')); + // Window 2: Postprocessing + $additional = $this->createWindowPane( + 2, + ' Post', + TmuxPaneRole::PostAdditional, + $this->getPaneDisplayName('postprocessing_additional'), + ); + $this->splitHorizontal( + $additional, + 50, + TmuxPaneRole::PostMovies, + $this->getPaneDisplayName('postprocessing_movies'), + ); + $tv = $this->splitVertical( + $additional, + 67, + TmuxPaneRole::PostTv, + $this->getPaneDisplayName('postprocessing_tv'), + ); + $this->splitVertical( + $tv, + 50, + TmuxPaneRole::PostMetadata, + $this->getPaneDisplayName('postprocessing_amazon'), + ); // Window 3: IRC Scraper $this->createIRCScraperWindow(); $this->createOptionalWindows(); - return true; } /** * Build stripped sequential layout (mode 2) */ - protected function buildStrippedLayout(): bool + protected function buildStrippedLayout(): void { // Window 0: Monitor + Sequential - if (! $this->sessionManager->createSession($this->getPaneDisplayName('Monitor'))) { - return false; - } - - // Select pane 0.0, then split for sequential - $this->paneManager->selectPane('0.0'); - $this->paneManager->splitHorizontal('0', '67%', $this->getPaneDisplayName('sequential')); + $monitor = $this->createSessionPane(TmuxPaneRole::Monitor, $this->getPaneDisplayName('Monitor')); + $this->splitHorizontal( + $monitor, + 67, + TmuxPaneRole::Sequential, + $this->getPaneDisplayName('sequential'), + ); // Window 1: Metadata postprocessing - $this->paneManager->createWindow(1, ' Utils'); - $this->paneManager->setPaneTitle('1.0', $this->getPaneDisplayName('fixReleaseNames')); - $this->paneManager->selectPane('1.0'); - $this->paneManager->splitHorizontal('1', '50%', $this->getPaneDisplayName('postprocessing_amazon')); + $fixNames = $this->createWindowPane( + 1, + ' Utils', + TmuxPaneRole::FixNames, + $this->getPaneDisplayName('fixReleaseNames'), + ); + $this->splitHorizontal( + $fixNames, + 50, + TmuxPaneRole::PostMetadata, + $this->getPaneDisplayName('postprocessing_amazon'), + ); // Window 2: IRC Scraper $this->createIRCScraperWindow(); $this->createOptionalWindows(); - return true; } /** * Create IRC scraper window */ - protected function createIRCScraperWindow(): bool + protected function createIRCScraperWindow(): void { - $this->paneManager->createWindow(3, '󰻞 IRC'); - $this->paneManager->setPaneTitle('3.0', $this->getPaneDisplayName('scrapeIRC')); - - return true; + $this->createWindowPane( + 3, + '󰻞 IRC', + TmuxPaneRole::IrcScraper, + $this->getPaneDisplayName('scrapeIRC'), + ); } /** @@ -239,45 +306,45 @@ class TmuxLayoutBuilder // htop if ((int) Settings::settingValue('htop') === 1 && $this->commandExists('htop')) { - $this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('htop')); - $this->paneManager->respawnPane("{$windowIndex}.0", 'htop'); + $pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('htop'), TmuxPaneRole::Htop, $this->getPaneDisplayName('htop')); + $this->requireSuccess($this->paneManager->respawnPane($pane, 'htop'), 'respawn htop pane'); $windowIndex++; } // nmon if ((int) Settings::settingValue('nmon') === 1 && $this->commandExists('nmon')) { - $this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('nmon')); - $this->paneManager->respawnPane("{$windowIndex}.0", 'nmon -t'); + $pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('nmon'), TmuxPaneRole::Nmon, $this->getPaneDisplayName('nmon')); + $this->requireSuccess($this->paneManager->respawnPane($pane, 'nmon -t'), 'respawn nmon pane'); $windowIndex++; } // vnstat if ((int) Settings::settingValue('vnstat') === 1 && $this->commandExists('vnstat')) { $vnstatArgs = Settings::settingValue('vnstat_args') ?? ''; - $this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('vnstat')); - $this->paneManager->respawnPane("{$windowIndex}.0", "watch -n10 'vnstat {$vnstatArgs}'"); + $pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('vnstat'), TmuxPaneRole::Vnstat, $this->getPaneDisplayName('vnstat')); + $this->requireSuccess($this->paneManager->respawnPane($pane, "watch -n10 'vnstat {$vnstatArgs}'"), 'respawn vnstat pane'); $windowIndex++; } // tcptrack if ((int) Settings::settingValue('tcptrack') === 1 && $this->commandExists('tcptrack')) { $tcptrackArgs = Settings::settingValue('tcptrack_args') ?? ''; - $this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('tcptrack')); - $this->paneManager->respawnPane("{$windowIndex}.0", "tcptrack {$tcptrackArgs}"); + $pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('tcptrack'), TmuxPaneRole::Tcptrack, $this->getPaneDisplayName('tcptrack')); + $this->requireSuccess($this->paneManager->respawnPane($pane, "tcptrack {$tcptrackArgs}"), 'respawn tcptrack pane'); $windowIndex++; } // bwm-ng if ((int) Settings::settingValue('bwmng') === 1 && $this->commandExists('bwm-ng')) { - $this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('bwm-ng')); - $this->paneManager->respawnPane("{$windowIndex}.0", 'bwm-ng'); + $pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('bwm-ng'), TmuxPaneRole::BandwidthMonitor, $this->getPaneDisplayName('bwm-ng')); + $this->requireSuccess($this->paneManager->respawnPane($pane, 'bwm-ng'), 'respawn bwm-ng pane'); $windowIndex++; } // mytop if ((int) Settings::settingValue('mytop') === 1 && $this->commandExists('mytop')) { - $this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('mytop')); - $this->paneManager->respawnPane("{$windowIndex}.0", 'mytop -u'); + $pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('mytop'), TmuxPaneRole::Mytop, $this->getPaneDisplayName('mytop')); + $this->requireSuccess($this->paneManager->respawnPane($pane, 'mytop -u'), 'respawn mytop pane'); $windowIndex++; } @@ -286,13 +353,13 @@ class TmuxLayoutBuilder $redisArgs = Settings::settingValue('redis_args') ?? ''; $refreshInterval = 30; - $this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('redis')); + $pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('redis'), TmuxPaneRole::Redis, $this->getPaneDisplayName('redis')); // Check if custom args provided for simple redis-cli output if (! empty($redisArgs) && $redisArgs !== 'NULL' && $this->commandExists('redis-cli')) { $redisHost = config('database.redis.default.host', '127.0.0.1'); $redisPort = config('database.redis.default.port', 6379); - $this->paneManager->respawnPane("{$windowIndex}.0", "watch -n{$refreshInterval} -c 'redis-cli -h {$redisHost} -p {$redisPort} {$redisArgs}'"); + $this->requireSuccess($this->paneManager->respawnPane($pane, "watch -n{$refreshInterval} -c 'redis-cli -h {$redisHost} -p {$redisPort} {$redisArgs}'"), 'respawn Redis pane'); } else { // Use PHP-based Redis monitor service $redisHost = config('database.redis.default.host', '127.0.0.1'); @@ -305,11 +372,11 @@ class TmuxLayoutBuilder if ($connectionInfo['use_sail']) { // Use sail to run inside Docker container $sail = base_path('sail'); - $this->paneManager->respawnPane("{$windowIndex}.0", "{$sail} artisan redis:monitor --refresh={$refreshInterval}"); + $this->requireSuccess($this->paneManager->respawnPane($pane, "{$sail} artisan redis:monitor --refresh={$refreshInterval}"), 'respawn Redis pane'); } else { // Run directly, potentially with host override $envPrefix = $connectionInfo['override_host'] ? "REDIS_HOST={$connectionInfo['host']} " : ''; - $this->paneManager->respawnPane("{$windowIndex}.0", "{$envPrefix}php {$artisan} redis:monitor --refresh={$refreshInterval}"); + $this->requireSuccess($this->paneManager->respawnPane($pane, "{$envPrefix}php {$artisan} redis:monitor --refresh={$refreshInterval}"), 'respawn Redis pane'); } } $windowIndex++; @@ -317,8 +384,78 @@ class TmuxLayoutBuilder // bash console if ((int) Settings::settingValue('console') === 1) { - $this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('bash')); - $this->paneManager->respawnPane("{$windowIndex}.0", 'bash -i'); + $pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('bash'), TmuxPaneRole::Console, $this->getPaneDisplayName('bash')); + $this->requireSuccess($this->paneManager->respawnPane($pane, 'bash -i'), 'respawn console pane'); + } + } + + public function lastError(): ?string + { + return $this->lastError; + } + + private function createSessionPane(TmuxPaneRole $role, string $title): string + { + $paneId = $this->sessionManager->createSession($title); + if ($paneId === null) { + throw new RuntimeException($this->sessionManager->lastError() ?? 'Unable to create tmux session.'); + } + + $this->sessionCreated = true; + $this->configurePane($paneId, $role, $title); + + return $paneId; + } + + private function createWindowPane(int $index, string $windowName, TmuxPaneRole $role, string $title): string + { + $paneId = $this->paneManager->createWindow($index, $windowName); + if ($paneId === null) { + throw new RuntimeException($this->paneManager->lastError() ?? "Unable to create tmux window {$index}."); + } + + $this->configurePane($paneId, $role, $title); + + return $paneId; + } + + private function splitHorizontal(string $target, int $percentage, TmuxPaneRole $role, string $title): string + { + $paneId = $this->paneManager->splitHorizontal($target, $percentage); + if ($paneId === null) { + throw new RuntimeException($this->paneManager->lastError() ?? "Unable to split tmux pane {$target}."); + } + + $this->configurePane($paneId, $role, $title); + + return $paneId; + } + + private function splitVertical(string $target, int $percentage, TmuxPaneRole $role, string $title): string + { + $paneId = $this->paneManager->splitVertical($target, $percentage); + if ($paneId === null) { + throw new RuntimeException($this->paneManager->lastError() ?? "Unable to split tmux pane {$target}."); + } + + $this->configurePane($paneId, $role, $title); + + return $paneId; + } + + private function configurePane(string $paneId, TmuxPaneRole $role, string $title): void + { + $this->requireSuccess($this->paneManager->setPaneTitle($paneId, $title), "set title for {$role->value} pane"); + $this->requireSuccess($this->paneManager->setPaneRole($paneId, $role), "tag {$role->value} pane"); + } + + private function requireSuccess(bool $successful, string $operation): void + { + if (! $successful) { + $error = $this->paneManager->lastError(); + $suffix = $error === null || $error === '' ? '' : ": {$error}"; + + throw new RuntimeException("Unable to {$operation}{$suffix}."); } } @@ -328,7 +465,7 @@ class TmuxLayoutBuilder protected function commandExists(string $command): bool { $result = Process::timeout(5) - ->run("which {$command} 2>/dev/null"); + ->run(['which', $command]); return $result->successful() && str_contains($result->output(), $command); } diff --git a/app/Services/Tmux/TmuxPaneManager.php b/app/Services/Tmux/TmuxPaneManager.php index 2c5778f33..4a8128562 100644 --- a/app/Services/Tmux/TmuxPaneManager.php +++ b/app/Services/Tmux/TmuxPaneManager.php @@ -4,15 +4,27 @@ declare(strict_types=1); namespace App\Services\Tmux; +use App\Enums\TmuxPaneRole; +use Illuminate\Contracts\Process\ProcessResult; use Illuminate\Support\Facades\Process; +use RuntimeException; /** * Service for managing individual tmux panes */ class TmuxPaneManager { + private const ROLE_OPTION = '@nntmux_role'; + protected string $sessionName; + /** + * @var array|null + */ + private ?array $roleTargets = null; + + private ?string $lastError = null; + public function __construct(string $sessionName) { $this->sessionName = $sessionName; @@ -21,48 +33,37 @@ class TmuxPaneManager /** * Create a new window */ - public function createWindow(int $index, string $name): bool + public function createWindow(int $index, string $name): ?string { - // Escape single quotes in the name for shell - $escapedName = str_replace("'", "'\\''", $name); - $result = Process::timeout(10)->run( - "tmux new-window -t {$this->sessionName}:{$index} -n '{$escapedName}' 'printf \"\\033]2;{$escapedName}\\033\"'" + ['tmux', 'new-window', '-P', '-F', '#{pane_id}', '-t', "{$this->sessionName}:{$index}", '-n', $name, 'true'] ); - return $result->successful(); + return $this->createdPaneId($result->successful(), $result->output(), $result->errorOutput()); } /** * Split a pane horizontally */ - public function splitHorizontal(string $target, string $percentage, string $title = ''): bool + public function splitHorizontal(string $target, int $percentage): ?string { - // Escape single quotes in the title for shell - $escapedTitle = str_replace("'", "'\\''", $title); - $titleCmd = $title ? "printf \"\\033]2;{$escapedTitle}\\033\"" : ''; - $result = Process::timeout(10)->run( - "tmux splitw -t {$this->sessionName}:{$target} -h -l {$percentage} '{$titleCmd}'" + ['tmux', 'split-window', '-P', '-F', '#{pane_id}', '-t', $this->target($target), '-h', '-l', "{$percentage}%", 'true'] ); - return $result->successful(); + return $this->createdPaneId($result->successful(), $result->output(), $result->errorOutput()); } /** * Split a pane vertically */ - public function splitVertical(string $target, string $percentage, string $title = ''): bool + public function splitVertical(string $target, int $percentage): ?string { - // Escape single quotes in the title for shell - $escapedTitle = str_replace("'", "'\\''", $title); - $titleCmd = $title ? "printf \"\\033]2;{$escapedTitle}\\033\"" : ''; - $result = Process::timeout(10)->run( - "tmux splitw -t {$this->sessionName}:{$target} -v -l {$percentage} '{$titleCmd}'" + ['tmux', 'split-window', '-P', '-F', '#{pane_id}', '-t', $this->target($target), '-v', '-l', "{$percentage}%", 'true'] ); - return $result->successful(); + return $this->createdPaneId($result->successful(), $result->output(), $result->errorOutput()); } /** @@ -71,10 +72,10 @@ class TmuxPaneManager public function selectPane(string $target): bool { $result = Process::timeout(5)->run( - "tmux selectp -t {$this->sessionName}:{$target}" + ['tmux', 'select-pane', '-t', $this->target($target)] ); - return $result->successful(); + return $this->recordResult($result); } /** @@ -83,10 +84,10 @@ class TmuxPaneManager public function selectWindow(int $window): bool { $result = Process::timeout(5)->run( - "tmux select-window -t {$this->sessionName}:{$window}" + ['tmux', 'select-window', '-t', "{$this->sessionName}:{$window}"] ); - return $result->successful(); + return $this->recordResult($result); } /** @@ -94,17 +95,17 @@ class TmuxPaneManager */ public function respawnPane(string $target, string $command, bool $kill = false): bool { - $killFlag = $kill ? '-k' : ''; - - // Escape the command for tmux by replacing double quotes with escaped quotes - $escapedCommand = str_replace('"', '\\"', $command); - $escapedCommand = str_replace('$', '\\$', $escapedCommand); + $arguments = ['tmux', 'respawn-pane']; + if ($kill) { + $arguments[] = '-k'; + } + array_push($arguments, '-t', $this->target($target), $command); $result = Process::timeout(10)->run( - "tmux respawnp {$killFlag} -t {$this->sessionName}:{$target} \"{$escapedCommand}\"" + $arguments ); - return $result->successful(); + return $this->recordResult($result); } /** @@ -112,13 +113,14 @@ class TmuxPaneManager */ public function sendKeys(string $target, string $keys, bool $enter = true): bool { - $enterKey = $enter ? ' Enter' : ''; - $result = Process::timeout(5)->run( - "tmux send-keys -t {$this->sessionName}:{$target} '{$keys}'{$enterKey}" + array_values(array_filter( + ['tmux', 'send-keys', '-t', $this->target($target), '--', $keys, $enter ? 'Enter' : null], + static fn (?string $argument): bool => $argument !== null, + )) ); - return $result->successful(); + return $this->recordResult($result); } /** @@ -127,10 +129,10 @@ class TmuxPaneManager public function killPane(string $target): bool { $result = Process::timeout(5)->run( - "tmux kill-pane -t {$this->sessionName}:{$target}" + ['tmux', 'kill-pane', '-t', $this->target($target)] ); - return $result->successful(); + return $this->recordResult($result); } /** @@ -138,14 +140,11 @@ class TmuxPaneManager */ public function setPaneTitle(string $target, string $title): bool { - // Escape single quotes in the title for shell - $escapedTitle = str_replace("'", "'\\''", $title); - $result = Process::timeout(5)->run( - "tmux select-pane -t {$this->sessionName}:{$target} -T '{$escapedTitle}'" + ['tmux', 'select-pane', '-t', $this->target($target), '-T', $title] ); - return $result->successful(); + return $this->recordResult($result); } /** @@ -154,7 +153,7 @@ class TmuxPaneManager public function getPaneTitle(string $target): ?string { $result = Process::timeout(5)->run( - "tmux display-message -p -t {$this->sessionName}:{$target} '#{pane_title}'" + ['tmux', 'display-message', '-p', '-t', $this->target($target), '#{pane_title}'] ); return $result->successful() ? trim($result->output()) : null; @@ -166,9 +165,146 @@ class TmuxPaneManager public function capturePane(string $target, int $lines = 100): string { $result = Process::timeout(10)->run( - "tmux capture-pane -p -t {$this->sessionName}:{$target} -S -{$lines}" + ['tmux', 'capture-pane', '-p', '-t', $this->target($target), '-S', "-{$lines}"] ); return $result->successful() ? $result->output() : ''; } + + public function setPaneRole(string $target, TmuxPaneRole $role): bool + { + $result = Process::timeout(5)->run( + ['tmux', 'set-option', '-p', '-t', $this->target($target), self::ROLE_OPTION, $role->value] + ); + + if ($this->recordResult($result)) { + $this->roleTargets = null; + + return true; + } + + return false; + } + + public function paneForRole(TmuxPaneRole $role, ?string $legacyTarget = null): string + { + $targets = $this->roleTargets(); + + if (isset($targets[$role->value])) { + return $targets[$role->value]; + } + + if ($legacyTarget !== null) { + return $this->tagLegacyPane($role, $legacyTarget); + } + + throw new RuntimeException("Tmux pane role '{$role->value}' was not found in session '{$this->sessionName}'."); + } + + public function lastError(): ?string + { + return $this->lastError; + } + + /** + * @return array + */ + private function roleTargets(): array + { + if ($this->roleTargets !== null) { + return $this->roleTargets; + } + + $result = Process::timeout(10)->run( + ['tmux', 'list-panes', '-s', '-t', $this->sessionName, '-F', "#{pane_id}\t#{".self::ROLE_OPTION.'}'] + ); + + if (! $result->successful()) { + throw new RuntimeException( + "Unable to list panes for tmux session '{$this->sessionName}': ".trim($result->errorOutput()) + ); + } + + $targets = []; + foreach (preg_split('/\R/', trim($result->output())) ?: [] as $line) { + if ($line === '') { + continue; + } + + [$paneId, $role] = array_pad(explode("\t", $line, 2), 2, ''); + if ($role === '') { + continue; + } + + if (isset($targets[$role])) { + throw new RuntimeException( + "Tmux pane role '{$role}' is assigned more than once in session '{$this->sessionName}'." + ); + } + + $targets[$role] = $paneId; + } + + return $this->roleTargets = $targets; + } + + private function target(string $target): string + { + return str_starts_with($target, '%') ? $target : "{$this->sessionName}:{$target}"; + } + + private function tagLegacyPane(TmuxPaneRole $role, string $legacyTarget): string + { + $result = Process::timeout(5)->run( + ['tmux', 'display-message', '-p', '-t', $this->target($legacyTarget), '#{pane_id}'] + ); + $paneId = trim($result->output()); + + if (! $result->successful() || ! preg_match('/^%[0-9]+$/', $paneId)) { + throw new RuntimeException( + "Tmux pane role '{$role->value}' and legacy target '{$legacyTarget}' were not found in session '{$this->sessionName}'." + ); + } + + if (! $this->setPaneRole($paneId, $role)) { + throw new RuntimeException( + "Unable to tag legacy tmux pane '{$legacyTarget}' as '{$role->value}': {$this->lastError}." + ); + } + + return $paneId; + } + + private function createdPaneId(bool $successful, string $output, string $errorOutput): ?string + { + if (! $successful) { + $this->lastError = trim($errorOutput); + + return null; + } + + $paneId = trim($output); + if (! preg_match('/^%[0-9]+$/', $paneId)) { + $this->lastError = "Tmux returned an invalid pane ID: '{$paneId}'."; + + return null; + } + + $this->lastError = null; + + return $paneId; + } + + private function recordResult(ProcessResult $result): bool + { + if ($result->successful()) { + $this->lastError = null; + + return true; + } + + $this->lastError = trim($result->errorOutput()); + + return false; + } } diff --git a/app/Services/Tmux/TmuxSessionManager.php b/app/Services/Tmux/TmuxSessionManager.php index f5e96bc44..9104cd51b 100644 --- a/app/Services/Tmux/TmuxSessionManager.php +++ b/app/Services/Tmux/TmuxSessionManager.php @@ -16,6 +16,8 @@ class TmuxSessionManager protected string $configFile; + private ?string $lastError = null; + public function __construct(?string $sessionName = null) { $this->sessionName = $sessionName ?? $this->getSessionName(); @@ -32,13 +34,18 @@ class TmuxSessionManager ?? config('tmux.session.default_name', 'nntmux'); } + public function sessionName(): string + { + return $this->sessionName; + } + /** * Check if tmux session exists */ public function sessionExists(): bool { $result = Process::timeout(10) - ->run("tmux list-sessions 2>/dev/null | grep -q '^{$this->sessionName}:'"); + ->run(['tmux', 'has-session', '-t', $this->sessionName]); return $result->successful(); } @@ -46,22 +53,50 @@ class TmuxSessionManager /** * Create a new tmux session */ - public function createSession(string $windowName = 'Monitor'): bool + public function createSession(string $windowName = 'Monitor'): ?string { if ($this->sessionExists()) { - return false; + $this->lastError = "Tmux session '{$this->sessionName}' already exists."; + + return null; } - $configOption = file_exists($this->configFile) ? "-f {$this->configFile}" : ''; - - // Escape single quotes in the window name for shell - $escapedWindowName = str_replace("'", "'\\''", $windowName); - - $result = Process::timeout(30)->run( - "tmux {$configOption} new-session -d -s {$this->sessionName} -n '{$escapedWindowName}' 'printf \"\\033]2;{$escapedWindowName}\\033\"'" + $arguments = ['tmux']; + if (file_exists($this->configFile)) { + array_push($arguments, '-f', $this->configFile); + } + array_push( + $arguments, + 'new-session', + '-d', + '-P', + '-F', + '#{pane_id}', + '-s', + $this->sessionName, + '-n', + $windowName, + 'true', ); - return $result->successful(); + $result = Process::timeout(30)->run($arguments); + + if (! $result->successful()) { + $this->lastError = trim($result->errorOutput()); + + return null; + } + + $paneId = trim($result->output()); + if (! preg_match('/^%[0-9]+$/', $paneId)) { + $this->lastError = "Tmux returned an invalid pane ID: '{$paneId}'."; + + return null; + } + + $this->lastError = null; + + return $paneId; } /** @@ -73,7 +108,7 @@ class TmuxSessionManager return true; } - $result = Process::timeout(30)->run("tmux kill-session -t {$this->sessionName}"); + $result = Process::timeout(30)->run(['tmux', 'kill-session', '-t', $this->sessionName]); return $result->successful(); } @@ -87,11 +122,9 @@ class TmuxSessionManager return false; } - $result = Process::timeout(5)->run( - "tmux select-window -t {$this->sessionName}:0; tmux attach-session -d -t {$this->sessionName}" - ); + passthru('tmux attach-session -t '.escapeshellarg($this->sessionName), $exitCode); - return $result->successful(); + return $exitCode === 0; } /** @@ -106,7 +139,7 @@ class TmuxSessionManager } $result = Process::timeout(10)->run( - "tmux list-panes -s -t {$this->sessionName} -F '#{window_index}:#{pane_index} #{pane_title}'" + ['tmux', 'list-panes', '-s', '-t', $this->sessionName, '-F', "#{window_index}:#{pane_index}\t#{pane_title}"] ); if (! $result->successful()) { @@ -121,7 +154,7 @@ class TmuxSessionManager continue; } - [$position, $title] = explode(' ', $line, 2); + [$position, $title] = array_pad(explode("\t", $line, 2), 2, ''); $panes[$position] = $title; } @@ -134,7 +167,7 @@ class TmuxSessionManager public function getPaneStatus(string $window, string $pane): ?string { $result = Process::timeout(5)->run( - "tmux display-message -p -t {$this->sessionName}:{$window}.{$pane} '#{pane_current_command}'" + ['tmux', 'display-message', '-p', '-t', "{$this->sessionName}:{$window}.{$pane}", '#{pane_current_command}'] ); return $result->successful() ? trim($result->output()) : null; @@ -149,4 +182,9 @@ class TmuxSessionManager return $status !== null && $status !== 'bash' && $status !== 'sh'; } + + public function lastError(): ?string + { + return $this->lastError; + } } diff --git a/app/Services/Tmux/TmuxTaskRunner.php b/app/Services/Tmux/TmuxTaskRunner.php index bd49cf86c..636e8a03b 100644 --- a/app/Services/Tmux/TmuxTaskRunner.php +++ b/app/Services/Tmux/TmuxTaskRunner.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Services\Tmux; +use App\Enums\TmuxPaneRole; use App\Models\Settings; /** @@ -49,15 +50,18 @@ class TmuxTaskRunner */ public function runTask(string $taskName, array $config): bool { - $pane = $config['pane'] ?? null; + $role = $config['role'] ?? null; $command = $config['command'] ?? null; $enabled = $config['enabled'] ?? true; $workAvailable = $config['work_available'] ?? true; - if (! $pane || ! $command) { + if (! $role instanceof TmuxPaneRole || ! $command) { return false; } + $legacyPane = $config['legacy_pane'] ?? null; + $pane = $this->paneManager->paneForRole($role, is_string($legacyPane) ? $legacyPane : null); + // Check if task is enabled and has work if (! $enabled) { return $this->disablePane($pane, $taskName, 'disabled in settings'); @@ -183,7 +187,7 @@ class TmuxTaskRunner public function runIRCScraper(array $config): bool { $runScraper = (int) ($config['constants']['run_ircscraper'] ?? 0); - $pane = '3.0'; + $pane = $this->paneManager->paneForRole(TmuxPaneRole::IrcScraper, '3.0'); if ($runScraper !== 1) { return $this->disablePane($pane, 'IRC Scraper', 'disabled in settings'); @@ -206,7 +210,7 @@ class TmuxTaskRunner { $enabled = (int) ($config['settings']['binaries_run'] ?? 0); $killswitch = $config['killswitch']['pp'] ?? false; - $pane = '0.1'; + $pane = $this->paneManager->paneForRole(TmuxPaneRole::Binaries, '0.1'); if (! $enabled) { return $this->disablePane($pane, 'Update Binaries', 'disabled in settings'); @@ -243,7 +247,7 @@ class TmuxTaskRunner $enabled = (int) ($config['settings']['backfill'] ?? 0); $collKillswitch = $config['killswitch']['coll'] ?? false; $ppKillswitch = $config['killswitch']['pp'] ?? false; - $pane = '0.2'; + $pane = $this->paneManager->paneForRole(TmuxPaneRole::Backfill, '0.2'); if (! $enabled) { return $this->disablePane($pane, 'Backfill', 'disabled in settings'); @@ -287,7 +291,8 @@ class TmuxTaskRunner public function runReleasesUpdate(array $config): bool { $enabled = (int) ($config['settings']['releases_run'] ?? 0); - $pane = $config['pane'] ?? '0.3'; + $legacyPane = (int) ($config['constants']['sequential'] ?? 0) === 1 ? '0.1' : '0.3'; + $pane = $this->paneManager->paneForRole(TmuxPaneRole::Releases, $legacyPane); if (! $enabled) { return $this->disablePane($pane, 'Update Releases', 'disabled in settings'); @@ -354,7 +359,7 @@ class TmuxTaskRunner // For now, delegate to existing methods $this->runBinariesUpdate($runVar); $this->runBackfill($runVar); - $this->runReleasesUpdate(array_merge($runVar, ['pane' => '0.3'])); + $this->runReleasesUpdate($runVar); return true; } @@ -366,7 +371,7 @@ class TmuxTaskRunner */ protected function runMainBasic(array $runVar): bool { - return $this->runReleasesUpdate(array_merge($runVar, ['pane' => '0.1'])); + return $this->runReleasesUpdate($runVar); } /** @@ -377,7 +382,7 @@ class TmuxTaskRunner protected function runMainSequential(array $runVar): bool { // Full sequential mode - runs group:update-all for each group - $pane = '0.1'; + $pane = $this->paneManager->paneForRole(TmuxPaneRole::Sequential, '0.1'); $niceness = $this->getNiceness(); $artisan = base_path('artisan'); @@ -396,7 +401,7 @@ class TmuxTaskRunner { $enabled = (int) ($runVar['settings']['fix_names'] ?? 0); $work = (int) ($runVar['counts']['now']['processrenames'] ?? 0); - $pane = '1.0'; + $pane = $this->paneManager->paneForRole(TmuxPaneRole::FixNames, '1.0'); if ($enabled !== 1) { return $this->disablePane($pane, 'Fix Release Names', 'disabled in settings'); @@ -431,7 +436,7 @@ class TmuxTaskRunner protected function runRemoveCrapTask(array $runVar): bool { $option = $runVar['settings']['fix_crap_opt'] ?? 'Disabled'; - $pane = '1.1'; + $pane = $this->paneManager->paneForRole(TmuxPaneRole::RemoveCrap, '1.1'); // Handle disabled state if ($option === 'Disabled' || $option === 0 || $option === '0') { @@ -545,7 +550,7 @@ class TmuxTaskRunner protected function runPostProcessAdditional(array $runVar): bool { $postSetting = (int) ($runVar['settings']['post'] ?? 0); - $pane = '2.0'; + $pane = $this->paneManager->paneForRole(TmuxPaneRole::PostAdditional, '2.0'); // Check if post processing is enabled (1 = additional, 2 = nfo, 3 = both) if ($postSetting === 0) { @@ -610,7 +615,7 @@ class TmuxTaskRunner protected function runTvTask(array $runVar): bool { $enabled = (int) ($runVar['settings']['post_non'] ?? 0); - $pane = '2.1'; + $pane = $this->paneManager->paneForRole(TmuxPaneRole::PostTv, '2.1'); if ($enabled !== 1) { return $this->disablePane($pane, 'Post-process TV/Anime', 'disabled in settings'); @@ -670,7 +675,7 @@ class TmuxTaskRunner protected function runMoviesTask(array $runVar): bool { $enabled = (int) ($runVar['settings']['post_non'] ?? 0); - $pane = '2.3'; + $pane = $this->paneManager->paneForRole(TmuxPaneRole::PostMovies, '2.3'); if ($enabled !== 1) { return $this->disablePane($pane, 'Post-process Movies', 'disabled in settings'); @@ -720,7 +725,8 @@ class TmuxTaskRunner protected function runAmazonTask(array $runVar): bool { $enabled = (int) ($runVar['settings']['post_amazon'] ?? 0); - $pane = '2.2'; + $legacyPane = (int) ($runVar['constants']['sequential'] ?? 0) === 2 ? '1.1' : '2.2'; + $pane = $this->paneManager->paneForRole(TmuxPaneRole::PostMetadata, $legacyPane); if ($enabled !== 1) { return $this->disablePane($pane, 'Post-process Metadata', 'disabled in settings'); diff --git a/tests/Feature/Console/TmuxHealthCheckCommandTest.php b/tests/Feature/Console/TmuxHealthCheckCommandTest.php index e1faeff43..b3fe37675 100644 --- a/tests/Feature/Console/TmuxHealthCheckCommandTest.php +++ b/tests/Feature/Console/TmuxHealthCheckCommandTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tests\Feature\Console; use Illuminate\Contracts\Console\Kernel; +use Illuminate\Process\PendingProcess; use Illuminate\Support\Facades\Process; use PDO; use Tests\TestCase; @@ -88,7 +89,7 @@ class TmuxHealthCheckCommandTest extends TestCase ->expectsOutputToContain("Tmux session 'test-session' does not exist.") ->assertExitCode(0); - Process::assertRanTimes("tmux list-sessions 2>/dev/null | grep -q '^test-session:'", 1); + Process::assertRanTimes(fn (PendingProcess $process): bool => $this->isTmuxCommand($process, 'has-session'), 1); Process::assertNotRan('which tmux 2>/dev/null'); } @@ -101,7 +102,7 @@ class TmuxHealthCheckCommandTest extends TestCase ->expectsOutputToContain("Tmux session 'test-session' does not exist.") ->assertExitCode(1); - Process::assertRanTimes("tmux list-sessions 2>/dev/null | grep -q '^test-session:'", 1); + Process::assertRanTimes(fn (PendingProcess $process): bool => $this->isTmuxCommand($process, 'has-session'), 1); Process::assertNotRan('which tmux 2>/dev/null'); } @@ -115,25 +116,50 @@ class TmuxHealthCheckCommandTest extends TestCase ->expectsOutputToContain("Tmux session 'test-session' restarted successfully.") ->assertExitCode(0); - Process::assertRanTimes("tmux list-sessions 2>/dev/null | grep -q '^test-session:'", 4); + Process::assertRanTimes(fn (PendingProcess $process): bool => $this->isTmuxCommand($process, 'has-session'), 4); Process::assertRan('which tmux 2>/dev/null'); - Process::assertRan(fn ($process): bool => str_contains($process->command, 'new-session -d -s test-session')); + Process::assertRan(fn (PendingProcess $process): bool => $this->isTmuxCommand($process, 'new-session')); } private function fakeMissingSession(): void { - Process::fake([ - "tmux list-sessions 2>/dev/null | grep -q '^test-session:'" => Process::result('', '', 1), - ]); + Process::fake(fn () => Process::result('', '', 1)); } private function fakeMissingSessionWithSuccessfulStart(): void { - Process::fake([ - "tmux list-sessions 2>/dev/null | grep -q '^test-session:'" => Process::result('', '', 1), - 'which tmux 2>/dev/null' => Process::result('/usr/bin/tmux'.PHP_EOL), - '*' => Process::result(), - ]); + $nextPaneId = 1; + + Process::fake(function (PendingProcess $process) use (&$nextPaneId) { + if ($process->command === 'which tmux 2>/dev/null') { + return Process::result('/usr/bin/tmux'.PHP_EOL); + } + + if (! is_array($process->command)) { + return Process::result(); + } + + if (in_array('has-session', $process->command, true)) { + return Process::result('', '', 1); + } + + if (in_array('new-session', $process->command, true) + || in_array('new-window', $process->command, true) + || in_array('split-window', $process->command, true)) { + return Process::result('%'.$nextPaneId++."\n"); + } + + if (in_array('list-panes', $process->command, true)) { + return Process::result("%1\tmonitor\n"); + } + + return Process::result(); + }); + } + + private function isTmuxCommand(PendingProcess $process, string $command): bool + { + return is_array($process->command) && in_array($command, $process->command, true); } private function setSetting(string $name, string $value): void diff --git a/tests/Feature/TmuxPaneManagementTest.php b/tests/Feature/TmuxPaneManagementTest.php new file mode 100644 index 000000000..6bf6dd081 --- /dev/null +++ b/tests/Feature/TmuxPaneManagementTest.php @@ -0,0 +1,297 @@ + Process::result("%12\tmonitor\n%27\tpost_movies\n"), + ]); + + $manager = new TmuxPaneManager('test session'); + + $this->assertSame('%12', $manager->paneForRole(TmuxPaneRole::Monitor)); + $this->assertSame('%27', $manager->paneForRole(TmuxPaneRole::PostMovies)); + + Process::assertRanTimes( + fn (PendingProcess $process): bool => is_array($process->command) + && in_array('list-panes', $process->command, true), + 1, + ); + } + + public function test_duplicate_roles_are_rejected(): void + { + Process::fake([ + '*' => Process::result("%12\tmonitor\n%27\tmonitor\n"), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("role 'monitor' is assigned more than once"); + + (new TmuxPaneManager('test-session'))->paneForRole(TmuxPaneRole::Monitor); + } + + public function test_missing_roles_are_rejected(): void + { + Process::fake([ + '*' => Process::result("%12\tmonitor\n"), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("role 'post_movies' was not found"); + + (new TmuxPaneManager('test-session'))->paneForRole(TmuxPaneRole::PostMovies); + } + + public function test_legacy_coordinate_is_tagged_during_role_resolution(): void + { + Process::fake(function (PendingProcess $process) { + if (is_array($process->command) && in_array('list-panes', $process->command, true)) { + return Process::result("%8\t\n"); + } + + if (is_array($process->command) && in_array('display-message', $process->command, true)) { + return Process::result("%8\n"); + } + + return Process::result(); + }); + + $manager = new TmuxPaneManager('legacy-session'); + + $this->assertSame('%8', $manager->paneForRole(TmuxPaneRole::Monitor, '0.0')); + + Process::assertRan(function (PendingProcess $process): bool { + return $process->command === [ + 'tmux', + 'set-option', + '-p', + '-t', + '%8', + '@nntmux_role', + 'monitor', + ]; + }); + } + + public function test_respawn_passes_the_command_as_one_argument(): void + { + Process::fake(); + + $command = <<<'SH' +echo "$HOME" && printf '%s\n' 'quoted value' +SH; + + $this->assertTrue((new TmuxPaneManager('test session'))->respawnPane('%42', $command, kill: true)); + + Process::assertRan(function (PendingProcess $process) use ($command): bool { + return $process->command === [ + 'tmux', + 'respawn-pane', + '-k', + '-t', + '%42', + $command, + ]; + }); + } + + #[DataProvider('layoutRolesProvider')] + public function test_layouts_tag_every_logical_pane(int $mode, array $expectedRoles): void + { + $nextPaneId = 1; + $assignedRoles = []; + $splitTargets = []; + + Process::fake(function (PendingProcess $process) use (&$nextPaneId, &$assignedRoles, &$splitTargets) { + $command = $process->command; + if (! is_array($command)) { + return Process::result(); + } + + if (in_array('has-session', $command, true)) { + return Process::result('', '', 1); + } + + if (in_array('new-session', $command, true) + || in_array('new-window', $command, true) + || in_array('split-window', $command, true)) { + if (in_array('split-window', $command, true)) { + $splitTargets[] = $command[array_search('-t', $command, true) + 1]; + } + + return Process::result('%'.$nextPaneId++."\n"); + } + + $roleOptionIndex = array_search('@nntmux_role', $command, true); + if ($roleOptionIndex !== false) { + $assignedRoles[] = $command[$roleOptionIndex + 1]; + } + + return Process::result(); + }); + + $sessionManager = new TmuxSessionManager('test-session'); + $layoutBuilder = new class($sessionManager) extends TmuxLayoutBuilder + { + protected function createOptionalWindows(): void {} + }; + + $this->assertTrue($layoutBuilder->buildLayout($mode), (string) $layoutBuilder->lastError()); + $this->assertEqualsCanonicalizing($expectedRoles, $assignedRoles); + + foreach ($splitTargets as $target) { + $this->assertStringStartsWith('%', $target); + } + } + + public static function layoutRolesProvider(): array + { + return [ + 'full' => [ + 0, + [ + 'monitor', + 'binaries', + 'backfill', + 'releases', + 'fix_names', + 'remove_crap', + 'post_additional', + 'post_movies', + 'post_tv', + 'post_metadata', + 'irc_scraper', + ], + ], + 'basic' => [ + 1, + [ + 'monitor', + 'releases', + 'fix_names', + 'remove_crap', + 'post_additional', + 'post_movies', + 'post_tv', + 'post_metadata', + 'irc_scraper', + ], + ], + 'stripped' => [ + 2, + [ + 'monitor', + 'sequential', + 'fix_names', + 'post_metadata', + 'irc_scraper', + ], + ], + ]; + } + + public function test_partial_layout_is_removed_after_a_split_failure(): void + { + $sessionExists = false; + $splitAttempts = 0; + + Process::fake(function (PendingProcess $process) use (&$sessionExists, &$splitAttempts) { + $command = $process->command; + if (! is_array($command)) { + return Process::result(); + } + + if (in_array('has-session', $command, true)) { + return Process::result('', '', $sessionExists ? 0 : 1); + } + + if (in_array('new-session', $command, true)) { + $sessionExists = true; + + return Process::result("%1\n"); + } + + if (in_array('split-window', $command, true)) { + $splitAttempts++; + if ($splitAttempts === 2) { + return Process::result('', 'split failed', 1); + } + + return Process::result("%2\n"); + } + + if (in_array('kill-session', $command, true)) { + $sessionExists = false; + } + + return Process::result(); + }); + + $sessionManager = new TmuxSessionManager('test-session'); + $layoutBuilder = new class($sessionManager) extends TmuxLayoutBuilder + { + protected function createOptionalWindows(): void {} + }; + + $this->assertFalse($layoutBuilder->buildLayout(0)); + $this->assertSame('split failed', $layoutBuilder->lastError()); + $this->assertFalse($sessionExists); + + Process::assertRan( + fn (PendingProcess $process): bool => is_array($process->command) + && in_array('kill-session', $process->command, true), + ); + } + + public function test_task_runner_resolves_a_role_before_respawning(): void + { + Process::fake(function (PendingProcess $process) { + if (is_array($process->command) && in_array('list-panes', $process->command, true)) { + return Process::result("%9\tpost_movies\n"); + } + + return Process::result(); + }); + + $runner = new TmuxTaskRunner('test-session'); + + $this->assertTrue($runner->runTask('Movies', [ + 'role' => TmuxPaneRole::PostMovies, + 'command' => 'php artisan example', + ])); + + Process::assertRan(function (PendingProcess $process): bool { + return $process->command === [ + 'tmux', + 'respawn-pane', + '-t', + '%9', + 'php artisan example', + ]; + }); + } +}