This commit is contained in:
2025-10-13 17:33:22 +05:00
parent 0e3ad8e065
commit 8a29cdc27a
8 changed files with 411 additions and 49 deletions
+15 -2
View File
@@ -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() {
</div>
{/* Pixel Streaming Player - показывается когда сессия запущена */}
{session.status === "started" && (
{session.status === "started" && session.playerPort && (
<div className="mb-6 aspect-video">
<PixelStreamingWrapper
initialSettings={{
ss: "ws://127.0.0.1:8080",
ss: `ws://127.0.0.1:${session.playerPort}`,
AutoPlayVideo: true,
AutoConnect: true,
StartVideoMuted: true,
@@ -190,6 +194,15 @@ function SessionPage() {
{session.cirrusPid && (
<InfoRow label="PID Cirrus" value={session.cirrusPid} />
)}
{session.streamerPort && (
<InfoRow label="Streamer Port" value={session.streamerPort} />
)}
{session.playerPort && (
<InfoRow label="Player Port" value={session.playerPort} />
)}
{session.sfuPort && (
<InfoRow label="SFU Port" value={session.sfuPort} />
)}
</div>
</div>
+4 -1
View File
@@ -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",
},
+24
View File
@@ -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" })),
}),
}
+3
View File
@@ -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
@@ -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;
}
+1
View File
@@ -15,6 +15,7 @@
# production
/build
/dist
/SignallingWebServer
# misc
.DS_Store
@@ -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"
}
+331 -46
View File
@@ -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<string, ChildProcess>();
// Карта активных Cirrus процессов: sessionId -> процесс Cirrus
const activeCirrusProcesses = new Map<string, ChildProcess>();
// Карта сессий в процессе запуска/остановки для предотвращения дублирования
const processingSessions = new Set<string>();
@@ -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<number> {
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<void> {
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<void> {
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<void> {
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<void> {
);
}
// Запускаем 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<void> {
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<void> {
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<void> {
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<void> {
}
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<void> {
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<void> {
}
}
// Останавливаем 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()}] ❌ Ошибка остановки приложения:`,