Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions bin/swoole-server
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/*
|--------------------------------------------------------------------------
Expand All @@ -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';

/*
|--------------------------------------------------------------------------
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
57 changes: 57 additions & 0 deletions src/Commands/Concerns/ResolvesSymlinks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?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.
*/
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();
}
}
5 changes: 5 additions & 0 deletions src/Commands/ReloadCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
15 changes: 10 additions & 5 deletions src/Commands/StartFrankenPhpCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -76,6 +77,8 @@ public function handle(ServerProcessInspector $inspector, ServerStateFile $serve

$this->forgetEnvironmentVariables();

$basePath = $this->resolveBasePath();

$host = $this->getHost();
$port = $this->getPort();

Expand All @@ -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(),
Expand Down Expand Up @@ -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");
}

/**
Expand Down
11 changes: 7 additions & 4 deletions src/Commands/StartRoadRunnerCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(),
Expand All @@ -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();

Expand Down
18 changes: 15 additions & 3 deletions src/Commands/StartSwooleCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();

Expand Down
53 changes: 53 additions & 0 deletions tests/ResolvesSymlinksTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?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();
}
};
}
}
Loading