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); } }