Files
ladill-servers/app/Services/Hosting/BrowserTerminalSessionManager.php
T
isaaccladandCursor b6c8ac343f Extract Ladill Servers as standalone app at servers.ladill.com.
VPS and dedicated server ordering, managed panels, SSO, billing, and launcher integration — forked from hosting infrastructure with server-focused routes and dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 19:18:30 +00:00

374 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;
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
{
$deadline = microtime(true) + 2.5;
while (microtime(true) < $deadline) {
usleep(100000);
$current = $this->readWorkerMetadata($sessionId);
if (($current['status'] ?? 'starting') !== 'starting') {
return $current;
}
}
$metadata['status'] = 'failed';
$metadata['exit_code'] = 1;
$metadata['closed_at'] = now()->toIso8601String();
$metadata['error'] = $this->workerBootstrapError($sessionId);
$this->writeMetadata($sessionId, $metadata);
return $metadata;
}
private function workerBootstrapError(string $sessionId): string
{
$output = trim((string) @file_get_contents($this->outputLogPath($sessionId)));
if ($output !== '') {
$lines = preg_split("/\r\n|\n|\r/", $output) ?: [];
$tail = trim((string) end($lines));
if ($tail !== '') {
return 'Terminal worker failed to start: '.$tail;
}
}
return 'Terminal worker failed to start.';
}
}