Created
March 18, 2026 08:42
-
-
Save JoshSalway/0a1be675960307433dabf05e95f32e40 to your computer and use it in GitHub Desktop.
Fix symlink-based zero-downtime deployments for Octane (Swoole, RoadRunner, FrankenPHP) - laravel/octane#1004
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| diff --git a/bin/swoole-server b/bin/swoole-server | |
| index 3fa19c2..957b5e6 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, | |
| @@ -58,10 +64,10 @@ $server->on('start', fn (Server $server) => $bootstrap($serverState) && (new OnS | |
| $serverState['octaneConfig']['tick'] ?? true | |
| ))($server)); | |
| -$server->on('managerstart', function () use ($serverState) { | |
| +$server->on('managerstart', function () use ($serverState, $binDir) { | |
| // Don't bootstrap entire application before server / worker start. Otherwise, files can't be gracefully reloaded... #632 | |
| - require_once __DIR__.'/../src/Swoole/Handlers/OnManagerStart.php'; | |
| - require_once __DIR__.'/../src/Swoole/SwooleExtension.php'; | |
| + require_once $binDir.'/../src/Swoole/Handlers/OnManagerStart.php'; | |
| + require_once $binDir.'/../src/Swoole/SwooleExtension.php'; | |
| (new OnManagerStart( | |
| new SwooleExtension, $serverState['appName'] | |
| @@ -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 0000000..680d690 | |
| --- /dev/null | |
| +++ b/src/Commands/Concerns/ResolvesSymlinks.php | |
| @@ -0,0 +1,59 @@ | |
| +<?php | |
| + | |
| +namespace Laravel\Octane\Commands\Concerns; | |
| + | |
| +trait ResolvesSymlinks | |
| +{ | |
| + /** | |
| + * Resolve the application base path, following symlinks to detect | |
| + * zero-downtime deployment strategies (e.g. Deployer, Envoyer). | |
| + * | |
| + * When the application is started via a symlinked path (e.g. /var/www/current -> /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. | |
| + * | |
| + * @return string|false | |
| + */ | |
| + 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 ea7d9f7..af4068f 100644 | |
| --- a/src/Commands/ReloadCommand.php | |
| +++ b/src/Commands/ReloadCommand.php | |
| @@ -31,6 +31,11 @@ class ReloadCommand extends Command | |
| */ | |
| public function handle() | |
| { | |
| + // Clear PHP's 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 e4abbfa..ba9a374 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; | |
| } | |
| @@ -85,14 +86,16 @@ public function handle(ServerProcessInspector $inspector, ServerStateFile $serve | |
| ? "https://$host:$port" | |
| : "http://:$port"; | |
| + $basePath = $this->resolveBasePath(); | |
| + | |
| $process = tap(new Process([ | |
| $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(), | |
| diff --git a/src/Commands/StartRoadRunnerCommand.php b/src/Commands/StartRoadRunnerCommand.php | |
| index 8f75679..97022b1 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 4d31dbc..587b62d 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 0000000..e56b13a | |
| --- /dev/null | |
| +++ b/tests/ResolvesSymlinksTest.php | |
| @@ -0,0 +1,52 @@ | |
| +<?php | |
| + | |
| +namespace Laravel\Octane\Tests; | |
| + | |
| +use Laravel\Octane\Commands\Concerns\ResolvesSymlinks; | |
| +use PHPUnit\Framework\TestCase; | |
| + | |
| +class ResolvesSymlinksTest extends TestCase | |
| +{ | |
| + public function test_get_symlink_aware_cwd_returns_pwd_env_when_set() | |
| + { | |
| + $trait = $this->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(); | |
| + } | |
| + }; | |
| + } | |
| +} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment