diff --git a/bin/swoole-server b/bin/swoole-server index 3fa19c249..ba06651bb 100755 --- a/bin/swoole-server +++ b/bin/swoole-server @@ -13,11 +13,13 @@ use Swoole\Timer; ini_set('display_errors', 'stderr'); -require_once __DIR__.'/../src/Stream.php'; +$binDir = getenv('APP_RELEASE_BIN_DIR') ?: __DIR__; -require __DIR__.'/../fixes/fix-symfony-dd.php'; +require_once $binDir.'/../src/Stream.php'; -$bootstrap = fn ($serverState) => require __DIR__.'/bootstrap.php'; +require $binDir.'/../fixes/fix-symfony-dd.php'; + +$bootstrap = fn ($serverState) => require $binDir.'/bootstrap.php'; /* |-------------------------------------------------------------------------- @@ -34,9 +36,9 @@ $serverState = json_decode(file_get_contents( $serverStateFile = $_SERVER['argv'][1] ), true)['state']; -$server = require __DIR__.'/createSwooleServer.php'; +$server = require $binDir.'/createSwooleServer.php'; -$timerTable = require __DIR__.'/createSwooleTimerTable.php'; +$timerTable = require $binDir.'/createSwooleTimerTable.php'; /* |-------------------------------------------------------------------------- @@ -49,6 +51,10 @@ $timerTable = require __DIR__.'/createSwooleTimerTable.php'; | */ +$server->on('beforereload', function (Server $server) { + clearstatcache(true); +}); + $server->on('start', fn (Server $server) => $bootstrap($serverState) && (new OnServerStart( new ServerStateFile($serverStateFile), new SwooleExtension, @@ -83,13 +89,13 @@ $server->on('managerstart', function () use ($serverState) { | */ -require_once __DIR__.'/WorkerState.php'; +require_once $binDir.'/WorkerState.php'; $workerState = new WorkerState; -$workerState->cacheTable = require __DIR__.'/createSwooleCacheTable.php'; +$workerState->cacheTable = require $binDir.'/createSwooleCacheTable.php'; $workerState->timerTable = $timerTable; -$workerState->tables = require __DIR__.'/createSwooleTables.php'; +$workerState->tables = require $binDir.'/createSwooleTables.php'; $server->on('workerstart', fn (Server $server, $workerId) => (fn ($basePath) => (new OnWorkerStart( diff --git a/src/Commands/Concerns/ResolvesSymlinks.php b/src/Commands/Concerns/ResolvesSymlinks.php new file mode 100644 index 000000000..e47de6b5d --- /dev/null +++ b/src/Commands/Concerns/ResolvesSymlinks.php @@ -0,0 +1,57 @@ + /var/www/releases/v5), + * PHP's realpath() resolves to the target directory (/var/www/releases/v5). This breaks + * octane:reload because workers continue loading files from the old resolved path even + * after the symlink is atomically switched to a new release. + * + * This method detects the original symlinked working directory (the path the user actually + * invoked artisan from) and returns it so that workers always load files through the + * symlink, picking up new releases after a reload. + * + * @return string The symlink-aware base path, or the real base path if no symlink is detected. + */ + protected function resolveBasePath(): string + { + $realBasePath = base_path(); + + // Detect the working directory the process was actually started from. + // This preserves symlinked paths rather than resolving them. + $cwd = $this->getSymlinkAwareCwd(); + + if ($cwd !== false && $cwd !== $realBasePath) { + return $cwd; + } + + return $realBasePath; + } + + /** + * Get the current working directory without resolving symlinks. + * + * On Unix-like systems, `pwd` (without -P) returns the logical path + * which preserves symlinks. PHP's getcwd() always resolves symlinks. + */ + protected function getSymlinkAwareCwd(): string|false + { + if (PHP_OS_FAMILY === 'Windows') { + return getcwd(); + } + + $pwd = getenv('PWD'); + + if ($pwd !== false && is_dir($pwd) && realpath($pwd) === realpath(getcwd())) { + return $pwd; + } + + return getcwd(); + } +} diff --git a/src/Commands/ReloadCommand.php b/src/Commands/ReloadCommand.php index ea7d9f7bf..9be4c3771 100644 --- a/src/Commands/ReloadCommand.php +++ b/src/Commands/ReloadCommand.php @@ -31,6 +31,11 @@ class ReloadCommand extends Command */ public function handle() { + // Clear PHP stat cache so symlinks are re-resolved after a + // zero-downtime deployment (e.g. Deployer, Envoyer) atomically + // switches the "current" symlink to a new release directory. + clearstatcache(true); + $server = $this->option('server') ?: config('octane.server'); return match ($server) { diff --git a/src/Commands/StartFrankenPhpCommand.php b/src/Commands/StartFrankenPhpCommand.php index e4abbfad4..5bf99add7 100644 --- a/src/Commands/StartFrankenPhpCommand.php +++ b/src/Commands/StartFrankenPhpCommand.php @@ -15,7 +15,8 @@ class StartFrankenPhpCommand extends Command implements SignalableCommandInterfa { use Concerns\InstallsFrankenPhpDependencies, Concerns\InteractsWithEnvironmentVariables, - Concerns\InteractsWithServers { + Concerns\InteractsWithServers, + Concerns\ResolvesSymlinks { Concerns\InteractsWithServers::writeServerRunningMessage as baseWriteServerRunningMessage; } @@ -76,6 +77,8 @@ public function handle(ServerProcessInspector $inspector, ServerStateFile $serve $this->forgetEnvironmentVariables(); + $basePath = $this->resolveBasePath(); + $host = $this->getHost(); $port = $this->getPort(); @@ -89,10 +92,10 @@ public function handle(ServerProcessInspector $inspector, ServerStateFile $serve $frankenphpBinary, 'run', '-c', $this->configPath(), - ], base_path(), [ + ], $basePath, [ 'APP_ENV' => app()->environment(), - 'APP_BASE_PATH' => base_path(), - 'APP_PUBLIC_PATH' => public_path(), + 'APP_BASE_PATH' => $basePath, + 'APP_PUBLIC_PATH' => $basePath.'/public', 'LARAVEL_OCTANE' => 1, 'MAX_REQUESTS' => $this->option('max-requests'), 'REQUEST_MAX_EXECUTION_TIME' => $this->maxExecutionTime(), @@ -224,7 +227,9 @@ protected function buildWatchConfig() return "\t\twatch"; } - return collect($paths)->map(fn ($path) => "\t\twatch ".base_path($path))->join("\n"); + $basePath = $this->resolveBasePath(); + + return collect($paths)->map(fn ($path) => "\t\twatch ".$basePath.'/'.$path)->join("\n"); } /** diff --git a/src/Commands/StartRoadRunnerCommand.php b/src/Commands/StartRoadRunnerCommand.php index 8f7567904..97022b108 100644 --- a/src/Commands/StartRoadRunnerCommand.php +++ b/src/Commands/StartRoadRunnerCommand.php @@ -16,7 +16,8 @@ class StartRoadRunnerCommand extends Command implements SignalableCommandInterfa { use Concerns\InstallsRoadRunnerDependencies, Concerns\InteractsWithEnvironmentVariables, - Concerns\InteractsWithServers; + Concerns\InteractsWithServers, + Concerns\ResolvesSymlinks; /** * The command's signature. @@ -78,12 +79,14 @@ public function handle(ServerProcessInspector $inspector, ServerStateFile $serve $this->forgetEnvironmentVariables(); + $basePath = $this->resolveBasePath(); + $server = tap(new Process(array_filter([ $roadRunnerBinary, '-c', $this->configPath(), '-o', 'version=3', '-o', 'http.address='.$this->getHost().':'.$this->getPort(), - '-o', 'server.command='.(new PhpExecutableFinder)->find().','.base_path(config('octane.roadrunner.command', 'vendor/bin/roadrunner-worker')), + '-o', 'server.command='.(new PhpExecutableFinder)->find().','.$basePath.'/'.config('octane.roadrunner.command', 'vendor/bin/roadrunner-worker'), '-o', 'http.pool.num_workers='.$this->workerCount(), '-o', 'http.pool.max_jobs='.$this->option('max-requests'), '-o', 'rpc.listen=tcp://'.$this->rpcHost().':'.$this->rpcPort(), @@ -95,9 +98,9 @@ public function handle(ServerProcessInspector $inspector, ServerStateFile $serve '-o', 'logs.output=stdout', '-o', 'logs.encoding=json', 'serve', - ]), base_path(), [ + ]), $basePath, [ 'APP_ENV' => app()->environment(), - 'APP_BASE_PATH' => base_path(), + 'APP_BASE_PATH' => $basePath, 'LARAVEL_OCTANE' => 1, ]))->start(); diff --git a/src/Commands/StartSwooleCommand.php b/src/Commands/StartSwooleCommand.php index 4d31dbc3a..587b62d19 100644 --- a/src/Commands/StartSwooleCommand.php +++ b/src/Commands/StartSwooleCommand.php @@ -14,7 +14,7 @@ #[AsCommand(name: 'octane:swoole')] class StartSwooleCommand extends Command implements SignalableCommandInterface { - use Concerns\InteractsWithEnvironmentVariables, Concerns\InteractsWithServers; + use Concerns\InteractsWithEnvironmentVariables, Concerns\InteractsWithServers, Concerns\ResolvesSymlinks; /** * The command's signature. @@ -78,14 +78,26 @@ public function handle( $this->forgetEnvironmentVariables(); + $basePath = $this->resolveBasePath(); + + $binDir = realpath(__DIR__.'/../../bin'); + + // When running inside a symlinked directory, remap the bin directory + // so that file paths go through the symlink rather than the resolved + // real path. This ensures workers reload from the correct release. + if ($basePath !== base_path()) { + $binDir = str_replace(base_path(), $basePath, $binDir); + } + $server = tap(new Process([ (new PhpExecutableFinder)->find(), ...config('octane.swoole.php_options', []), config('octane.swoole.command', 'swoole-server'), $serverStateFile->path(), - ], realpath(__DIR__.'/../../bin'), [ + ], $binDir, [ 'APP_ENV' => app()->environment(), - 'APP_BASE_PATH' => base_path(), + 'APP_BASE_PATH' => $basePath, + 'APP_RELEASE_BIN_DIR' => $binDir, 'LARAVEL_OCTANE' => 1, ]))->start(); diff --git a/tests/ResolvesSymlinksTest.php b/tests/ResolvesSymlinksTest.php new file mode 100644 index 000000000..27293d12e --- /dev/null +++ b/tests/ResolvesSymlinksTest.php @@ -0,0 +1,53 @@ +createTraitInstance(); + + $cwd = $trait->callGetSymlinkAwareCwd(); + + // Should return a string (either the PWD env var or getcwd()) + $this->assertIsString($cwd); + } + + public function test_get_symlink_aware_cwd_returns_string() + { + $trait = $this->createTraitInstance(); + + $result = $trait->callGetSymlinkAwareCwd(); + + $this->assertNotFalse($result); + $this->assertIsString($result); + } + + public function test_resolve_base_path_returns_string() + { + // This test verifies the trait method returns a valid path. + // In a non-symlinked environment, it should return base_path(). + $trait = $this->createTraitInstance(); + + $result = $trait->callGetSymlinkAwareCwd(); + + $this->assertDirectoryExists($result); + } + + protected function createTraitInstance() + { + return new class + { + use ResolvesSymlinks; + + public function callGetSymlinkAwareCwd(): string|false + { + return $this->getSymlinkAwareCwd(); + } + }; + } +}