Files
ladill-hosting/app/Services/Hosting/BrowserTerminalSessionManager.php
isaaccladandClaude Opus 4.8 ff3959b734
Deploy Ladill Hosting / deploy (push) Successful in 1m15s
Fix browser terminal falsely reporting 'failed to start'
createSession only waited 2.5s for the worker to flip starting->running, but
the worker cold-boots the framework (php artisan) + opens the shell, which under
load exceeds that. On timeout it also overwrote the (healthy) worker's metadata
to status=failed, so customers saw 'Terminal worker failed to start' even though
the shell was live. Wait up to 8s and return early once status is known; never
fabricate a failure — the worker records real boot errors itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:09:45 +00:00

364 lines
11 KiB
PHP

<?php
namespace App\Services\Hosting;
use App\Models\HostingAccount;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
class BrowserTerminalSessionManager
{
private const IDLE_TIMEOUT_SECONDS = 120;
// How long createSession() waits for the worker to cold-boot the framework
// and open the shell before returning. Generous so a slow/loaded boot isn't
// mistaken for a failure; the wait returns early once the status is known.
private const STARTUP_TIMEOUT_SECONDS = 8.0;
public function createSession(HostingAccount $account, int $userId, string $relativePath = '/public_html', int $cols = 120, int $rows = 30): array
{
$sessionId = (string) Str::uuid();
$directory = $this->sessionDirectory($sessionId);
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
throw new \RuntimeException('Unable to create browser terminal session directory.');
}
touch($this->eventLogPath($sessionId));
touch($this->outputLogPath($sessionId));
touch($this->heartbeatPath($sessionId));
$metadata = [
'session_id' => $sessionId,
'account_id' => $account->id,
'user_id' => $userId,
'username' => $account->username,
'relative_path' => $relativePath,
'cols' => $this->normalizeColumns($cols),
'rows' => $this->normalizeRows($rows),
'status' => 'starting',
'created_at' => now()->toIso8601String(),
'last_seen_at' => now()->toIso8601String(),
'pid' => null,
'exit_code' => null,
'error' => null,
];
$this->writeMetadata($sessionId, $metadata);
$workerCommand = $this->buildWorkerCommand($sessionId);
$result = Process::timeout(10)->run(['bash', '-lc', $workerCommand]);
$pid = trim($result->output());
if ($result->failed() || $pid === '' || ! ctype_digit($pid)) {
$metadata['status'] = 'failed';
$metadata['error'] = 'Unable to start the browser terminal worker.';
$this->writeMetadata($sessionId, $metadata);
throw new \RuntimeException($metadata['error']);
}
$metadata['pid'] = (int) $pid;
$this->writeMetadata($sessionId, $metadata);
$metadata = $this->waitForWorkerStartup($sessionId, $metadata);
return $metadata;
}
public function readOutput(HostingAccount $account, int $userId, string $sessionId, int $offset = 0): array
{
$metadata = $this->sessionMetadataFor($account, $userId, $sessionId);
$offset = max(0, $offset);
$outputPath = $this->outputLogPath($sessionId);
$size = is_file($outputPath) ? filesize($outputPath) : 0;
$chunk = '';
if ($size > $offset) {
$stream = fopen($outputPath, 'rb');
if ($stream !== false) {
fseek($stream, $offset);
$chunk = (string) stream_get_contents($stream);
fclose($stream);
}
}
$this->touchHeartbeat($sessionId);
return [
'output' => $chunk,
'offset' => $size,
'status' => $metadata['status'] ?? 'closed',
'exit_code' => $metadata['exit_code'] ?? null,
'error' => $metadata['error'] ?? null,
];
}
public function appendInput(HostingAccount $account, int $userId, string $sessionId, string $input): void
{
$this->sessionMetadataFor($account, $userId, $sessionId);
$this->touchHeartbeat($sessionId);
$this->appendEvent($sessionId, [
'type' => 'input',
'data' => $input,
]);
}
public function resizeSession(HostingAccount $account, int $userId, string $sessionId, int $cols, int $rows): void
{
$this->sessionMetadataFor($account, $userId, $sessionId);
$this->touchHeartbeat($sessionId);
$this->appendEvent($sessionId, [
'type' => 'resize',
'cols' => $this->normalizeColumns($cols),
'rows' => $this->normalizeRows($rows),
]);
}
public function closeSession(HostingAccount $account, int $userId, string $sessionId): void
{
$this->sessionMetadataFor($account, $userId, $sessionId);
$this->touchHeartbeat($sessionId);
$this->appendEvent($sessionId, [
'type' => 'close',
]);
}
public function readWorkerMetadata(string $sessionId): array
{
$metadata = $this->readMetadata($sessionId);
if ($metadata === null) {
throw new \RuntimeException('Browser terminal session not found.');
}
return $metadata;
}
public function writeWorkerMetadata(string $sessionId, array $metadata): void
{
$this->writeMetadata($sessionId, $metadata);
}
public function appendWorkerOutput(string $sessionId, string $output): void
{
if ($output === '') {
return;
}
file_put_contents($this->outputLogPath($sessionId), $output, FILE_APPEND | LOCK_EX);
}
public function readWorkerEvents(string $sessionId, int &$offset): array
{
$path = $this->eventLogPath($sessionId);
if (! is_file($path)) {
return [];
}
$stream = fopen($path, 'rb');
if ($stream === false) {
return [];
}
fseek($stream, $offset);
$payload = (string) stream_get_contents($stream);
$offset = ftell($stream) ?: $offset;
fclose($stream);
$events = [];
foreach (preg_split("/\r\n|\n|\r/", $payload) as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$decoded = json_decode($line, true);
if (is_array($decoded)) {
$events[] = $decoded;
}
}
return $events;
}
public function isSessionIdle(array $metadata): bool
{
$heartbeatTime = @filemtime($this->heartbeatPath((string) ($metadata['session_id'] ?? '')));
if ($heartbeatTime === false) {
return false;
}
return (time() - $heartbeatTime) >= self::IDLE_TIMEOUT_SECONDS;
}
private function sessionMetadataFor(HostingAccount $account, int $userId, string $sessionId): array
{
$metadata = $this->readMetadata($sessionId);
if ($metadata === null) {
throw new \RuntimeException('Browser terminal session not found.');
}
if (($metadata['account_id'] ?? null) !== $account->id || ($metadata['user_id'] ?? null) !== $userId) {
throw new \RuntimeException('Browser terminal session not found.');
}
return $metadata;
}
private function appendEvent(string $sessionId, array $payload): void
{
file_put_contents(
$this->eventLogPath($sessionId),
json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).PHP_EOL,
FILE_APPEND | LOCK_EX
);
}
private function readMetadata(string $sessionId): ?array
{
$path = $this->metadataPath($sessionId);
if (! is_file($path)) {
return null;
}
$decoded = json_decode((string) file_get_contents($path), true);
return is_array($decoded) ? $decoded : null;
}
private function writeMetadata(string $sessionId, array $metadata): void
{
file_put_contents(
$this->metadataPath($sessionId),
json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
LOCK_EX
);
}
private function metadataPath(string $sessionId): string
{
return $this->sessionDirectory($sessionId).'/meta.json';
}
private function eventLogPath(string $sessionId): string
{
return $this->sessionDirectory($sessionId).'/events.ndjson';
}
private function outputLogPath(string $sessionId): string
{
return $this->sessionDirectory($sessionId).'/output.log';
}
private function heartbeatPath(string $sessionId): string
{
return $this->sessionDirectory($sessionId).'/heartbeat';
}
private function sessionDirectory(string $sessionId): string
{
return storage_path('app/browser-terminal-sessions/'.$sessionId);
}
private function normalizeColumns(int $cols): int
{
return max(40, min($cols, 240));
}
private function normalizeRows(int $rows): int
{
return max(10, min($rows, 80));
}
private function touchHeartbeat(string $sessionId): void
{
touch($this->heartbeatPath($sessionId));
}
private function buildWorkerCommand(string $sessionId): string
{
return sprintf(
'cd %s && nohup %s artisan hosting:terminal-worker %s >> %s 2>&1 < /dev/null & echo $!',
escapeshellarg(base_path()),
escapeshellarg($this->resolvePhpCliBinary()),
escapeshellarg($sessionId),
escapeshellarg($this->outputLogPath($sessionId))
);
}
private function resolvePhpCliBinary(): string
{
$candidates = array_filter([
env('LADILL_PHP_CLI_BINARY'),
$this->isCliBinary(PHP_BINARY) ? PHP_BINARY : null,
$this->joinBinaryPath(PHP_BINDIR, 'php'),
$this->joinBinaryPath(PHP_BINDIR, 'php-cli'),
'/usr/bin/php',
'/usr/local/bin/php',
]);
foreach ($candidates as $candidate) {
if ($this->isCliBinary($candidate)) {
return $candidate;
}
}
return PHP_BINARY;
}
private function joinBinaryPath(string $directory, string $binary): string
{
return rtrim($directory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$binary;
}
private function isCliBinary(?string $path): bool
{
if (! is_string($path) || trim($path) === '') {
return false;
}
$basename = strtolower(basename($path));
if (str_contains($basename, 'fpm') || str_contains($basename, 'cgi')) {
return false;
}
return is_file($path) && is_executable($path);
}
private function waitForWorkerStartup(string $sessionId, array $metadata): array
{
// The loop returns the moment status leaves "starting" (running OR a
// genuine error the worker recorded), so a fast boot still returns fast.
$deadline = microtime(true) + self::STARTUP_TIMEOUT_SECONDS;
while (microtime(true) < $deadline) {
usleep(100000);
$current = $this->readWorkerMetadata($sessionId);
if (($current['status'] ?? 'starting') !== 'starting') {
return $current;
}
}
// Still starting after the wait: do NOT fabricate a failure — that also
// clobbered workers that were about to come up, which is exactly what made
// healthy terminals report "failed to start". Return current metadata; the
// browser keeps polling and the worker writes a real error itself if boot
// ultimately fails.
return $this->readWorkerMetadata($sessionId);
}
}