diff --git a/client/src/pages/SessionPage.tsx b/client/src/pages/SessionPage.tsx index 9432933..31710b8 100644 --- a/client/src/pages/SessionPage.tsx +++ b/client/src/pages/SessionPage.tsx @@ -21,6 +21,9 @@ interface Session { serverId: string | null; appPid: number | null; cirrusPid: number | null; + streamerPort: number | null; + playerPort: number | null; + sfuPort: number | null; startAt: string; endAt: string | null; createdAt: string; @@ -44,6 +47,7 @@ interface Session { id: string; email: string; role: string; + displayName: string; } | null; } @@ -146,11 +150,11 @@ function SessionPage() { {/* Pixel Streaming Player - показывается когда сессия запущена */} - {session.status === "started" && ( + {session.status === "started" && session.playerPort && (
)} + {session.streamerPort && ( + + )} + {session.playerPort && ( + + )} + {session.sfuPort && ( + + )}
diff --git a/client/src/pages/TestPage.tsx b/client/src/pages/TestPage.tsx index f6b9bdb..2c7d5a6 100644 --- a/client/src/pages/TestPage.tsx +++ b/client/src/pages/TestPage.tsx @@ -12,6 +12,9 @@ interface Session { serverId: string | null; appPid: number | null; cirrusPid: number | null; + streamerPort: number | null; + playerPort: number | null; + sfuPort: number | null; startAt: string; endAt: string | null; } @@ -29,7 +32,7 @@ function TestPage() { const response = await api .post("sessions", { json: { - appId: "2914d736-b928-461c-b58f-e5d35d8b605d", + appId: "c1a06420-ca72-4a89-893e-3fe669bbbb99", mode: "stream", tier: "demo", }, diff --git a/server/src/controllers/session.ts b/server/src/controllers/session.ts index a592f03..40cd612 100644 --- a/server/src/controllers/session.ts +++ b/server/src/controllers/session.ts @@ -17,10 +17,16 @@ export const sessionController = new Elysia({ prefix: "/sessions" }) status: sessionStatus, appPid, cirrusPid, + streamerPort, + playerPort, + sfuPort, } = body as { status?: "starting" | "started" | "ending" | "ended"; appPid?: number; cirrusPid?: number; + streamerPort?: number; + playerPort?: number; + sfuPort?: number; }; // Проверить, что сессия существует @@ -35,6 +41,9 @@ export const sessionController = new Elysia({ prefix: "/sessions" }) status: sessionStatus, appPid, cirrusPid, + streamerPort, + playerPort, + sfuPort, }); return { session: updatedSession }; @@ -51,6 +60,9 @@ export const sessionController = new Elysia({ prefix: "/sessions" }) ), appPid: t.Optional(t.Number()), cirrusPid: t.Optional(t.Number()), + streamerPort: t.Optional(t.Number()), + playerPort: t.Optional(t.Number()), + sfuPort: t.Optional(t.Number()), }), } ) @@ -253,11 +265,17 @@ export const sessionController = new Elysia({ prefix: "/sessions" }) status: sessionStatus, appPid, cirrusPid, + streamerPort, + playerPort, + sfuPort, endAt, } = body as { status?: "starting" | "started" | "ending" | "ended"; appPid?: number; cirrusPid?: number; + streamerPort?: number; + playerPort?: number; + sfuPort?: number; endAt?: string; }; @@ -276,6 +294,9 @@ export const sessionController = new Elysia({ prefix: "/sessions" }) status: sessionStatus, appPid, cirrusPid, + streamerPort, + playerPort, + sfuPort, endAt: endAt ? new Date(endAt) : undefined, }); @@ -293,6 +314,9 @@ export const sessionController = new Elysia({ prefix: "/sessions" }) ), appPid: t.Optional(t.Number()), cirrusPid: t.Optional(t.Number()), + streamerPort: t.Optional(t.Number()), + playerPort: t.Optional(t.Number()), + sfuPort: t.Optional(t.Number()), endAt: t.Optional(t.String({ format: "date-time" })), }), } diff --git a/server/src/db/schema/serverSessions.ts b/server/src/db/schema/serverSessions.ts index 3d4d02d..eca1c3d 100644 --- a/server/src/db/schema/serverSessions.ts +++ b/server/src/db/schema/serverSessions.ts @@ -26,6 +26,9 @@ export const serverSessions = pgTable("server_sessions", { endAt: timestamp("end_at", { withTimezone: true }), // Default 30 minutes from start_at appPid: integer("app_pid"), cirrusPid: integer("cirrus_pid"), + streamerPort: integer("streamer_port"), // Порт Cirrus для UE приложения (streamer) + playerPort: integer("player_port"), // Порт Cirrus для клиента (player/браузер) + sfuPort: integer("sfu_port"), // Порт Cirrus для SFU (Selective Forwarding Unit) mode: sessionModeEnum("mode").notNull(), // stream, local tier: serverTierEnum("tier"), // demo, prod (только для stream, nullable) status: sessionStatusEnum("status").notNull(), // starting, started, ending, ended diff --git a/server/src/services/serverSession/index.ts b/server/src/services/serverSession/index.ts index 55e1bee..e0617c7 100644 --- a/server/src/services/serverSession/index.ts +++ b/server/src/services/serverSession/index.ts @@ -19,6 +19,9 @@ export interface UpdateSessionParams { status?: SessionStatus; appPid?: number; cirrusPid?: number; + streamerPort?: number; + playerPort?: number; + sfuPort?: number; endAt?: Date; } @@ -103,6 +106,9 @@ export const serverSessionService = { endAt: serverSessions.endAt, appPid: serverSessions.appPid, cirrusPid: serverSessions.cirrusPid, + streamerPort: serverSessions.streamerPort, + playerPort: serverSessions.playerPort, + sfuPort: serverSessions.sfuPort, mode: serverSessions.mode, status: serverSessions.status, createdAt: serverSessions.createdAt, @@ -292,6 +298,18 @@ export const serverSessionService = { updateData.cirrusPid = params.cirrusPid; } + if (params.streamerPort !== undefined) { + updateData.streamerPort = params.streamerPort; + } + + if (params.playerPort !== undefined) { + updateData.playerPort = params.playerPort; + } + + if (params.sfuPort !== undefined) { + updateData.sfuPort = params.sfuPort; + } + if (params.endAt) { updateData.endAt = params.endAt; } diff --git a/session-server/.gitignore b/session-server/.gitignore index a68d131..3140421 100644 --- a/session-server/.gitignore +++ b/session-server/.gitignore @@ -15,6 +15,7 @@ # production /build /dist +/SignallingWebServer # misc .DS_Store diff --git a/session-server/logs/.6c25b665cc60dab90c3b387b2a7b4da90595b19b-audit.json b/session-server/logs/.6c25b665cc60dab90c3b387b2a7b4da90595b19b-audit.json new file mode 100644 index 0000000..a79fc95 --- /dev/null +++ b/session-server/logs/.6c25b665cc60dab90c3b387b2a7b4da90595b19b-audit.json @@ -0,0 +1,15 @@ +{ + "keep": { + "days": true, + "amount": 14 + }, + "auditLog": "logs\\.6c25b665cc60dab90c3b387b2a7b4da90595b19b-audit.json", + "files": [ + { + "date": 1760357222809, + "name": "logs\\server-2025-10-13.log", + "hash": "d8c9aa8fe14287c28a5e6d97abfc99880c7be890d8f58eb2229e747850d1ac12" + } + ], + "hashType": "sha256" +} \ No newline at end of file diff --git a/session-server/src/index.ts b/session-server/src/index.ts index 413c9b5..963a760 100644 --- a/session-server/src/index.ts +++ b/session-server/src/index.ts @@ -2,6 +2,29 @@ import got, { RequestError } from "got"; import os from "os"; import { execSync, spawn, ChildProcess } from "child_process"; import { existsSync } from "fs"; +import { createServer } from "net"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * Сессионный сервер для запуска UE приложений + * + * Поддерживает два режима работы: + * + * 1. STREAM режим: + * - Запускает Cirrus SignallingWebServer на случайном свободном порту (8080-9000) + * - Запускает UE приложение с Pixel Streaming, подключенным к Cirrus + * - Сохраняет в БД: appPid, cirrusPid, cirrusPort + * - Используется для удаленного доступа через браузер + * + * 2. LOCAL режим: + * - Запускает только UE приложение без Pixel Streaming + * - Сохраняет в БД: только appPid + * - Используется для локального запуска приложения + */ // Конфигурация const API_URL = process.env.API_URL || "http://localhost:3000"; @@ -29,9 +52,12 @@ const SESSION_CHECK_INTERVAL_MS = parseInt( // ID зарегистрированного сервера (заполняется после регистрации) let SERVER_ID: string | null = null; -// Карта активных процессов: sessionId -> процесс приложения +// Карта активных процессов приложений: sessionId -> процесс приложения (UE) const activeProcesses = new Map(); +// Карта активных Cirrus процессов: sessionId -> процесс Cirrus +const activeCirrusProcesses = new Map(); + // Карта сессий в процессе запуска/остановки для предотвращения дублирования const processingSessions = new Set(); @@ -73,6 +99,9 @@ interface SessionData { endAt: string | null; appPid: number | null; cirrusPid: number | null; + streamerPort: number | null; // Порт Cirrus для UE приложения (streamer) + playerPort: number | null; // Порт Cirrus для клиента (player/браузер) + sfuPort: number | null; // Порт Cirrus для SFU (Selective Forwarding Unit) mode: "stream" | "local"; status: "starting" | "started" | "ending" | "ended"; createdAt: string; @@ -112,6 +141,42 @@ function getLocalIp(): string { return "127.0.0.1"; } +/** + * Найти свободный порт в указанном диапазоне + */ +function findFreePort( + startPort: number = 8080, + endPort: number = 9000 +): Promise { + return new Promise((resolve, reject) => { + const tryPort = (port: number) => { + if (port > endPort) { + reject( + new Error( + `Не найден свободный порт в диапазоне ${startPort}-${endPort}` + ) + ); + return; + } + + const server = createServer(); + + server.listen(port, () => { + server.once("close", () => { + resolve(port); + }); + server.close(); + }); + + server.on("error", () => { + tryPort(port + 1); + }); + }; + + tryPort(startPort); + }); +} + /** * Получить свободную память GPU через nvidia-smi * Возвращает количество свободной памяти в МБ @@ -314,7 +379,10 @@ async function updateSessionStatus( sessionId: string, status: "starting" | "started" | "ending" | "ended", appPid?: number, - cirrusPid?: number + cirrusPid?: number, + streamerPort?: number, + playerPort?: number, + sfuPort?: number ): Promise { try { await got.patch(`${API_URL}/sessions/${sessionId}/status`, { @@ -322,6 +390,9 @@ async function updateSessionStatus( status, appPid, cirrusPid, + streamerPort, + playerPort, + sfuPort, }, timeout: { request: 5000, @@ -343,7 +414,7 @@ async function updateSessionStatus( * Запустить приложение для сессии */ async function startApplication(session: SessionData): Promise { - const { id: sessionId, app, serverId } = session; + const { id: sessionId, app, serverId, mode } = session; // Проверить, не обрабатывается ли уже эта сессия if (processingSessions.has(sessionId)) { @@ -379,9 +450,16 @@ async function startApplication(session: SessionData): Promise { console.log( `[${new Date().toISOString()}] 🚀 Запуск приложения "${ app.name - }" для сессии ${sessionId} (активных процессов: ${activeProcesses.size})` + }" для сессии ${sessionId} в режиме ${mode} (активных процессов: ${ + activeProcesses.size + })` ); + let streamerPort: number | undefined = undefined; + let playerPort: number | undefined = undefined; + let sfuPort: number | undefined = undefined; + let cirrusPid: number | undefined = undefined; + // Формируем путь к exe файлу приложения // Путь: C:\apps\{appName}\{appName}.exe const appPath = `C:\\apps\\${app.name}\\${app.name}.exe`; @@ -397,30 +475,160 @@ async function startApplication(session: SessionData): Promise { ); } - // Запускаем exe приложение - // Используем 'pipe' для stderr чтобы видеть ошибки, но 'ignore' для stdin/stdout - const appProcess = spawn( - appPath, - [ - "-PixelStreamingURL=ws://127.0.0.1:8888", - "-ForceRes", - "-ResX=1920", - "-ResY=1080", - "-Unattended", - "-RenderOffScreen", - ], - { - detached: false, - stdio: ["ignore", "ignore", "pipe"], // stdin: ignore, stdout: ignore, stderr: pipe - windowsHide: true, // Скрывать окно консоли на Windows - cwd: `C:\\apps\\${app.name}`, // Устанавливаем рабочую директорию приложения + // Для stream-режима запускаем Cirrus с тремя разными портами + if (mode === "stream") { + // Найти три свободных порта для Cirrus (смещая старт поиска, чтобы избежать зацикливания) + streamerPort = await findFreePort(8080, 9000); + playerPort = await findFreePort(Math.min(streamerPort + 1, 9000), 9000); + sfuPort = await findFreePort(Math.min(playerPort + 1, 9000), 9000); + + console.log( + `[${new Date().toISOString()}] 🔌 Найдены свободные порты - Streamer: ${streamerPort}, Player: ${playerPort}, SFU: ${sfuPort}` + ); + + // Запускаем Cirrus SignallingWebServer + // Путь к Cirrus: session-server/SignallingWebServer/dist/index.js + // __dirname указывает на session-server/src или session-server/dist (в зависимости от того, запущен ли скомпилированный код) + const cirrusPath = join( + __dirname, + "..", + "SignallingWebServer", + "dist", + "index.js" + ); + + console.log( + `[${new Date().toISOString()}] 🌐 Запуск Cirrus SignallingWebServer (Streamer: ${streamerPort}, Player: ${playerPort}, SFU: ${sfuPort})` + ); + console.log( + `[${new Date().toISOString()}] 📂 Путь к Cirrus: ${cirrusPath}` + ); + + const cirrusProcess = spawn( + "node", + [ + cirrusPath, + "--streamer_port", + streamerPort.toString(), + "--player_port", + playerPort.toString(), + "--sfu_port", + sfuPort.toString(), + "--max_players", + "0", + ], + { + detached: false, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + } + ); + + cirrusPid = cirrusProcess.pid; + + if (!cirrusPid) { + throw new Error("Не удалось получить PID процесса Cirrus"); } - ); + + // Сохранить Cirrus процесс + activeCirrusProcesses.set(sessionId, cirrusProcess); + + // Логирование stdout и stderr для Cirrus + if (cirrusProcess.stdout) { + cirrusProcess.stdout.on("data", (data) => { + console.log( + `[${new Date().toISOString()}] 🌐 Cirrus [${sessionId}]: ${data + .toString() + .trim()}` + ); + }); + } + + if (cirrusProcess.stderr) { + cirrusProcess.stderr.on("data", (data) => { + console.error( + `[${new Date().toISOString()}] 🔴 Cirrus STDERR [${sessionId}]: ${data + .toString() + .trim()}` + ); + }); + } + + + // Обработка завершения Cirrus процесса + cirrusProcess.on("exit", async (code, signal) => { + console.log( + `[${new Date().toISOString()}] 🛑 Cirrus для сессии ${sessionId} завершился с кодом ${code}${ + signal ? ` (сигнал: ${signal})` : "" + }` + ); + activeCirrusProcesses.delete(sessionId); + }); + + cirrusProcess.on("error", async (error) => { + console.error( + `[${new Date().toISOString()}] ❌ Ошибка процесса Cirrus для сессии ${sessionId}:`, + error + ); + activeCirrusProcesses.delete(sessionId); + }); + + // Ждём немного, чтобы Cirrus успел запуститься + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + // Запускаем UE приложение + let appProcess: ChildProcess; + + if (mode === "stream") { + // Stream-режим: запускаем с Pixel Streaming + appProcess = spawn( + appPath, + [ + `-PixelStreamingURL=ws://127.0.0.1:${streamerPort}`, + "-ForceRes", + "-ResX=1920", + "-ResY=1080", + "-Unattended", + "-RenderOffScreen", + ], + { + detached: false, + stdio: ["ignore", "ignore", "pipe"], + windowsHide: true, + cwd: `C:\\apps\\${app.name}`, + } + ); + console.log( + `[${new Date().toISOString()}] 🌐 Stream-режим: приложение подключается к Cirrus streamer_port ${streamerPort}` + ); + } else { + // Local-режим: запускаем без Pixel Streaming + appProcess = spawn( + appPath, + [ + "-ForceRes", + "-ResX=1920", + "-ResY=1080", + "-Unattended", + "-RenderOffScreen", + ], + { + detached: false, + stdio: ["ignore", "ignore", "pipe"], + windowsHide: true, + cwd: `C:\\apps\\${app.name}`, + } + ); + console.log( + `[${new Date().toISOString()}] 💻 Local-режим: приложение запускается без Pixel Streaming` + ); + } const appPid = appProcess.pid; if (!appPid) { - throw new Error("Не удалось получить PID процесса"); + throw new Error("Не удалось получить PID процесса приложения"); } // Сохранить процесс в карте активных процессов @@ -430,14 +638,14 @@ async function startApplication(session: SessionData): Promise { if (appProcess.stderr) { appProcess.stderr.on("data", (data) => { console.error( - `[${new Date().toISOString()}] 🔴 STDERR [${sessionId}]: ${data + `[${new Date().toISOString()}] 🔴 App STDERR [${sessionId}]: ${data .toString() .trim()}` ); }); } - // Обработка завершения процесса + // Обработка завершения процесса приложения appProcess.on("exit", async (code, signal) => { console.log( `[${new Date().toISOString()}] 🛑 Приложение для сессии ${sessionId} завершилось с кодом ${code}${ @@ -453,34 +661,82 @@ async function startApplication(session: SessionData): Promise { activeProcesses.delete(sessionId); + // Для stream-режима также останавливаем Cirrus если он ещё работает + if (mode === "stream") { + const cirrus = activeCirrusProcesses.get(sessionId); + if (cirrus) { + console.log( + `[${new Date().toISOString()}] 🛑 Остановка Cirrus для завершённой сессии ${sessionId}` + ); + cirrus.kill("SIGTERM"); + activeCirrusProcesses.delete(sessionId); + } + } + // Обновить статус на "ended" await updateSessionStatus(sessionId, "ended"); }); appProcess.on("error", async (error) => { console.error( - `[${new Date().toISOString()}] ❌ Ошибка процесса для сессии ${sessionId}:`, + `[${new Date().toISOString()}] ❌ Ошибка процесса приложения для сессии ${sessionId}:`, error ); activeProcesses.delete(sessionId); + // Для stream-режима также останавливаем Cirrus + if (mode === "stream") { + const cirrus = activeCirrusProcesses.get(sessionId); + if (cirrus) { + cirrus.kill("SIGTERM"); + activeCirrusProcesses.delete(sessionId); + } + } + // Обновить статус на "ended" в случае ошибки await updateSessionStatus(sessionId, "ended"); }); - // Обновить статус на "started" с PID - await updateSessionStatus(sessionId, "started", appPid); - - console.log( - `[${new Date().toISOString()}] ✅ Приложение запущено с PID ${appPid} (всего активных: ${ - activeProcesses.size - })` - ); + // Обновить статус на "started" с PID приложения + // Для stream-режима также сохраняем Cirrus PID и порты + if (mode === "stream" && cirrusPid && streamerPort && playerPort && sfuPort) { + await updateSessionStatus( + sessionId, + "started", + appPid, + cirrusPid, + streamerPort, + playerPort, + sfuPort + ); + console.log( + `[${new Date().toISOString()}] ✅ Приложение запущено с PID ${appPid}, Cirrus с PID ${cirrusPid} (Streamer: ${streamerPort}, Player: ${playerPort}, SFU: ${sfuPort}) (всего активных: ${ + activeProcesses.size + })` + ); + } else { + await updateSessionStatus(sessionId, "started", appPid); + console.log( + `[${new Date().toISOString()}] ✅ Приложение запущено с PID ${appPid} в режиме local (всего активных: ${ + activeProcesses.size + })` + ); + } } catch (error) { console.error( `[${new Date().toISOString()}] ❌ Ошибка запуска приложения:`, error instanceof Error ? error.message : error ); + + // Очистить Cirrus процесс если он был запущен (только для stream-режима) + if (mode === "stream") { + const cirrus = activeCirrusProcesses.get(sessionId); + if (cirrus) { + cirrus.kill("SIGTERM"); + activeCirrusProcesses.delete(sessionId); + } + } + // Обновить статус на "ended" в случае ошибки await updateSessionStatus(sessionId, "ended"); } finally { @@ -515,7 +771,7 @@ function killProcessTree(pid: number): void { * Остановить приложение для сессии */ async function stopApplication(session: SessionData): Promise { - const { id: sessionId, appPid } = session; + const { id: sessionId, appPid, cirrusPid, mode } = session; // Проверить, не обрабатывается ли уже эта сессия if (processingSessions.has(sessionId)) { @@ -523,8 +779,9 @@ async function stopApplication(session: SessionData): Promise { } const appProcess = activeProcesses.get(sessionId); + const cirrusProcess = activeCirrusProcesses.get(sessionId); - if (!appProcess && !appPid) { + if (!appProcess && !appPid && !cirrusProcess && !cirrusPid) { console.log( `[${new Date().toISOString()}] ⚠️ Приложение для сессии ${sessionId} не найдено в активных процессах` ); @@ -536,15 +793,22 @@ async function stopApplication(session: SessionData): Promise { processingSessions.add(sessionId); try { - console.log( - `[${new Date().toISOString()}] 🛑 Остановка приложения для сессии ${sessionId} (PID: ${ - appPid || appProcess?.pid || "неизвестен" - })` - ); + if (mode === "stream") { + console.log( + `[${new Date().toISOString()}] 🛑 Остановка приложения для сессии ${sessionId} (App PID: ${ + appPid || appProcess?.pid || "неизвестен" + }, Cirrus PID: ${cirrusPid || cirrusProcess?.pid || "неизвестен"})` + ); + } else { + console.log( + `[${new Date().toISOString()}] 🛑 Остановка приложения для сессии ${sessionId} (App PID: ${ + appPid || appProcess?.pid || "неизвестен" + }) в режиме local` + ); + } - // Используем PID из базы данных если он есть, иначе из процесса + // Останавливаем UE приложение const pidToKill = appPid || appProcess?.pid; - if (pidToKill) { // Убиваем весь процесс и все его дочерние процессы killProcessTree(pidToKill); @@ -557,14 +821,35 @@ async function stopApplication(session: SessionData): Promise { } } + // Останавливаем Cirrus процесс (только для stream-режима) + if (mode === "stream") { + const cirrusPidToKill = cirrusPid || cirrusProcess?.pid; + if (cirrusPidToKill) { + killProcessTree(cirrusPidToKill); + } else if (cirrusProcess) { + cirrusProcess.kill("SIGTERM"); + await new Promise((resolve) => setTimeout(resolve, 2000)); + if (!cirrusProcess.killed) { + cirrusProcess.kill("SIGKILL"); + } + } + activeCirrusProcesses.delete(sessionId); + } + activeProcesses.delete(sessionId); // Обновить статус на "ended" await updateSessionStatus(sessionId, "ended"); - console.log( - `[${new Date().toISOString()}] ✅ Приложение и все дочерние процессы остановлены для сессии ${sessionId}` - ); + if (mode === "stream") { + console.log( + `[${new Date().toISOString()}] ✅ Приложение и Cirrus остановлены для сессии ${sessionId}` + ); + } else { + console.log( + `[${new Date().toISOString()}] ✅ Приложение остановлено для сессии ${sessionId}` + ); + } } catch (error) { console.error( `[${new Date().toISOString()}] ❌ Ошибка остановки приложения:`,