Remove outdated documentation files for companies and migration guide; implement server session management features including server assignment and session status updates; enhance database schema for servers and server sessions with new fields and validation; add auto-assign functionality for unassigned sessions.
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
**/*.trace
|
||||
**/*.zip
|
||||
**/*.tar.gz
|
||||
**/*.tgz
|
||||
**/*.log
|
||||
package-lock.json
|
||||
**/*.bun
|
||||
@@ -0,0 +1,397 @@
|
||||
# Session Server
|
||||
|
||||
Сессионный сервер для автоматической регистрации на основном API сервере.
|
||||
|
||||
## Описание
|
||||
|
||||
Session Server - это приложение, которое автоматически регистрируется на основном сервере (`stream.graff.tech`) и периодически обновляет свою информацию. Это позволяет основному серверу знать о доступных сессионных серверах для распределения нагрузки.
|
||||
|
||||
## Возможности
|
||||
|
||||
- 🚀 Автоматическая регистрация при запуске
|
||||
- 🔄 Периодическое обновление информации о сервере (по умолчанию каждые 30 секунд после завершения предыдущего запроса)
|
||||
- 🎮 Автоматическое определение и обновление свободной GPU памяти через nvidia-smi (по умолчанию каждую секунду после завершения предыдущего запроса)
|
||||
- 🎯 Автоматическое управление игровыми сессиями
|
||||
- Запуск приложений для сессий со статусом "starting"
|
||||
- Остановка приложений для сессий со статусом "ending"
|
||||
- Отслеживание PID запущенных процессов
|
||||
- Автоматическое обновление статусов сессий на главном сервере
|
||||
- 🌐 Автоматическое определение локального IP адреса
|
||||
- 💻 Автоматическое определение hostname
|
||||
- ⚙️ Гибкая конфигурация через переменные окружения
|
||||
- 🔁 Автоматические повторные попытки при ошибках
|
||||
- 📝 Подробное логирование
|
||||
- ⚡ Защита от наложения запросов для всех операций (рекурсивный вызов через setTimeout)
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
# Установка зависимостей
|
||||
bun install
|
||||
```
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Создайте файл `.env` в корне проекта на основе `.env.example`:
|
||||
|
||||
```bash
|
||||
# URL основного API сервера
|
||||
API_URL=http://localhost:3000
|
||||
|
||||
# Тип сервера: stream или local
|
||||
SERVER_TYPE=stream
|
||||
|
||||
# Расположение сервера (ОБЯЗАТЕЛЬНО для stream серверов)
|
||||
# Возможные значения: ru1, uae1
|
||||
SERVER_LOCATION=ru1
|
||||
|
||||
# Уровень сервера (только для stream серверов)
|
||||
# Возможные значения: demo, prod
|
||||
SERVER_TIER=demo
|
||||
|
||||
# ID филиала (ОБЯЗАТЕЛЬНО для local серверов)
|
||||
BRANCH_ID=00000000-0000-0000-0000-000000000000
|
||||
|
||||
# Локальный IP адрес (опционально, определяется автоматически)
|
||||
# LOCAL_IP=192.168.1.100
|
||||
|
||||
# Hostname сервера (опционально, определяется автоматически)
|
||||
# HOSTNAME=my-session-server
|
||||
|
||||
# Интервал регистрации в миллисекундах (по умолчанию 30000 = 30 секунд)
|
||||
REGISTER_INTERVAL_MS=30000
|
||||
|
||||
# Интервал обновления GPU памяти в миллисекундах (по умолчанию 1000 = 1 секунда)
|
||||
GPU_UPDATE_INTERVAL_MS=1000
|
||||
|
||||
# Интервал проверки сессий в миллисекундах (по умолчанию 5000 = 5 секунд)
|
||||
SESSION_CHECK_INTERVAL_MS=5000
|
||||
```
|
||||
|
||||
### Переменные окружения
|
||||
|
||||
| Переменная | Описание | Обязательна | По умолчанию |
|
||||
|------------|----------|-------------|--------------|
|
||||
| `API_URL` | URL основного API сервера | Нет | `http://localhost:3000` |
|
||||
| `SERVER_TYPE` | Тип сервера (`stream` или `local`) | Нет | `stream` |
|
||||
| `SERVER_LOCATION` | Расположение (`ru1`, `uae1`) - **обязательно для `stream`** | Да (для stream) | - |
|
||||
| `SERVER_TIER` | Уровень (`demo`, `prod`) - только для `stream` | Нет | `demo` (для stream) |
|
||||
| `BRANCH_ID` | ID филиала - **обязательно для `local`** | Да (для local) | - |
|
||||
| `LOCAL_IP` | Локальный IP адрес | Нет | Определяется автоматически |
|
||||
| `HOSTNAME` | Hostname сервера | Нет | Определяется автоматически |
|
||||
| `REGISTER_INTERVAL_MS` | Интервал регистрации в мс | Нет | `30000` |
|
||||
| `GPU_UPDATE_INTERVAL_MS` | Интервал обновления GPU памяти в мс | Нет | `1000` |
|
||||
| `SESSION_CHECK_INTERVAL_MS` | Интервал проверки сессий в мс | Нет | `5000` |
|
||||
|
||||
**Примечание:** Свободная память GPU (`gpuFreeMb`) автоматически определяется через `nvidia-smi` при каждой регистрации и обновляется каждую секунду (или согласно `GPU_UPDATE_INTERVAL_MS`) после завершения предыдущего запроса. Это предотвращает наложение запросов. Если `nvidia-smi` недоступен, сервер завершит работу с ошибкой.
|
||||
|
||||
## Управление сессиями
|
||||
|
||||
Session Server автоматически управляет игровыми сессиями на этом сервере:
|
||||
|
||||
### Как это работает
|
||||
|
||||
1. **Проверка сессий**: Каждую секунду (или согласно `SESSION_CHECK_INTERVAL_MS`) сервер запрашивает у основного API список сессий для этого сервера
|
||||
2. **Запуск приложений**: Для сессий со статусом `starting`:
|
||||
- ⏰ Проверяется время `startAt` - приложение запускается только если это время уже наступило
|
||||
- **Timezone**: Все время хранится в UTC, сравнение корректно работает независимо от часового пояса сервера
|
||||
- Запланированные сессии (с будущим `startAt`) логируются с информацией о времени до запуска
|
||||
- 🎯 **Централизованное назначение сервера** (для stream-сессий):
|
||||
- **Main server** автоматически назначает серверы каждые 5 секунд
|
||||
- Выбирается сервер с максимальной свободной GPU памятью
|
||||
- Учитываются требования приложения к GPU памяти (`gpuLimitMb`)
|
||||
- Session-server просто проверяет, что сессия назначена ему
|
||||
- Запускается соответствующее приложение
|
||||
- Отслеживается PID процесса
|
||||
- Статус сессии обновляется на `started` на главном сервере
|
||||
3. **Остановка приложений**: Для сессий со статусом `ending`:
|
||||
- Используется `taskkill /pid {PID} /T /F` для завершения всего дерева процессов
|
||||
- `/T` - завершает указанный процесс и ВСЕ дочерние процессы
|
||||
- `/F` - принудительное завершение
|
||||
- Решает проблему с UE5 и другими приложениями, создающими дочерние процессы
|
||||
- Статус сессии обновляется на `ended` на главном сервере
|
||||
4. **Автоматическая очистка**: Процессы для неактивных сессий автоматически останавливаются
|
||||
|
||||
### API endpoints для управления сессиями
|
||||
|
||||
Session Server взаимодействует с основным сервером через следующие endpoints:
|
||||
|
||||
- `GET /servers/:id/sessions` - получить список сессий для сервера
|
||||
- `PATCH /sessions/:id/status` - обновить статус сессии (публичный endpoint)
|
||||
|
||||
### Запуск приложений
|
||||
|
||||
Session Server автоматически запускает `.exe` приложения по стандартному пути:
|
||||
|
||||
```
|
||||
C:\apps\{appName}\{appName}.exe
|
||||
```
|
||||
|
||||
Где `{appName}` - это значение поля `name` из таблицы `apps`.
|
||||
|
||||
#### Структура директорий
|
||||
|
||||
Все приложения должны быть размещены в следующей структуре:
|
||||
```
|
||||
C:\apps\
|
||||
├── minecraft\
|
||||
│ └── minecraft.exe
|
||||
├── fortnite\
|
||||
│ └── fortnite.exe
|
||||
└── cyberpunk\
|
||||
└── cyberpunk.exe
|
||||
```
|
||||
|
||||
#### Особенности запуска и остановки
|
||||
|
||||
- ✅ Автоматическая проверка существования exe файла
|
||||
- ✅ Рабочая директория устанавливается в папку приложения (`C:\apps\{appName}\`)
|
||||
- ✅ Окно консоли скрывается (`windowsHide: true`)
|
||||
- ✅ PID процесса отслеживается и передается на main server
|
||||
- ✅ Автоматическое обновление статуса при завершении процесса
|
||||
- ✅ **Корректное завершение дочерних процессов** - использует `taskkill /T` для завершения всего дерева процессов
|
||||
- ✅ Решает проблему с UE5 приложениями, которые создают множественные процессы
|
||||
|
||||
#### Логи запуска
|
||||
|
||||
```
|
||||
[2025-10-06T10:00:00.000Z] 🚀 Запуск приложения "minecraft" для сессии abc-123
|
||||
[2025-10-06T10:00:00.050Z] 📂 Путь к приложению: C:\apps\minecraft\minecraft.exe
|
||||
[2025-10-06T10:00:01.000Z] ✅ Приложение запущено с PID 12345
|
||||
[2025-10-06T10:00:01.100Z] ✅ Статус сессии abc-123 обновлен на "started"
|
||||
```
|
||||
|
||||
#### Обработка ошибок
|
||||
|
||||
Если exe файл не найден:
|
||||
```
|
||||
❌ Файл приложения не найден: C:\apps\minecraft\minecraft.exe. Убедитесь, что приложение установлено.
|
||||
```
|
||||
|
||||
### Примеры логов
|
||||
|
||||
Запланированная сессия:
|
||||
```
|
||||
[2025-10-06T10:00:00.000Z] ⏰ Сессия 123e4567-e89b-12d3-a456-426614174000 (minecraft) запланирована через 120 сек
|
||||
```
|
||||
|
||||
Запуск сессии:
|
||||
```
|
||||
[2025-10-06T10:02:00.000Z] 🚀 Запуск приложения "minecraft" для сессии 123e4567-e89b-12d3-a456-426614174000
|
||||
[2025-10-06T10:02:01.000Z] ✅ Приложение запущено с PID 12345
|
||||
[2025-10-06T10:02:01.100Z] ✅ Статус сессии 123e4567-e89b-12d3-a456-426614174000 обновлен на "started"
|
||||
```
|
||||
|
||||
Остановка сессии:
|
||||
```
|
||||
[2025-10-06T10:32:00.000Z] 🛑 Остановка приложения для сессии 123e4567-e89b-12d3-a456-426614174000 (PID: 12345)
|
||||
[2025-10-06T10:32:00.500Z] ✅ Дерево процессов для PID 12345 успешно завершено
|
||||
[2025-10-06T10:32:00.600Z] ✅ Приложение и все дочерние процессы остановлены для сессии 123e4567-e89b-12d3-a456-426614174000
|
||||
[2025-10-06T10:32:00.700Z] ✅ Статус сессии 123e4567-e89b-12d3-a456-426614174000 обновлен на "ended"
|
||||
```
|
||||
|
||||
## Отладка проблем (Troubleshooting)
|
||||
|
||||
Если приложение запускается и сразу завершается с ошибкой, см. подробное руководство по диагностике:
|
||||
- **[TROUBLESHOOTING.md](./TROUBLESHOOTING.md)**
|
||||
|
||||
### Новые возможности логирования
|
||||
|
||||
После обновления вы увидите:
|
||||
- 🔴 **STDERR логи** для диагностики ошибок приложений
|
||||
- 📊 **Счётчик активных процессов** при запуске/завершении
|
||||
- ⚠️ **Детальная информация** о кодах выхода и сигналах
|
||||
|
||||
Пример логов с ошибкой:
|
||||
```
|
||||
[10:51:56.498Z] 🚀 Запуск приложения "ShishimGorka" для сессии abc (активных процессов: 1)
|
||||
[10:51:56.563Z] ✅ Приложение запущено с PID 84112 (всего активных: 2)
|
||||
[10:51:57.500Z] 🔴 STDERR [abc]: Error: Port 8888 is already in use
|
||||
[10:51:57.894Z] 🛑 Приложение для сессии abc завершилось с кодом 1
|
||||
[10:51:57.894Z] ⚠️ Приложение завершилось с ошибкой! Код выхода: 1
|
||||
```
|
||||
|
||||
## Работа с часовыми поясами (Timezone)
|
||||
|
||||
Session Server корректно работает с разными часовыми поясами:
|
||||
|
||||
### Как это работает
|
||||
|
||||
1. **База данных**: PostgreSQL хранит все timestamp с timezone в UTC
|
||||
2. **API**: Основной сервер возвращает время в формате ISO 8601 с timezone (например, `2025-10-06T10:00:00.000Z`)
|
||||
3. **Session Server**: JavaScript автоматически парсит ISO 8601 строки в UTC
|
||||
4. **Сравнение**: Все сравнения времени происходят в UTC, независимо от локального часового пояса сервера
|
||||
|
||||
### Миграция базы данных
|
||||
|
||||
⚠️ **Важно**: Если вы обновляете существующую систему, необходимо выполнить миграцию для добавления timezone в поля `startAt` и `endAt`:
|
||||
|
||||
```bash
|
||||
# Из директории server/
|
||||
psql -d $DATABASE_URL -f timezone_migration.sql
|
||||
```
|
||||
|
||||
См. `server/TIMEZONE_MIGRATION.md` для подробностей.
|
||||
|
||||
### Примеры
|
||||
|
||||
Если сервер в России создаёт сессию на 14:00 по московскому времени (UTC+3):
|
||||
- В БД сохранится: `2025-10-06T11:00:00.000Z` (UTC)
|
||||
- Сервер в ОАЭ (UTC+4) увидит: 15:00 по местному времени
|
||||
- Session Server запустит приложение ровно в 11:00 UTC, независимо от локального времени
|
||||
|
||||
## Запуск
|
||||
|
||||
### Development режим
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
### Production режим
|
||||
|
||||
```bash
|
||||
# Собрать проект
|
||||
bun run build
|
||||
|
||||
# Запустить собранное приложение
|
||||
bun run start
|
||||
```
|
||||
|
||||
## Примеры использования
|
||||
|
||||
### Stream сервер в России (демо)
|
||||
|
||||
```env
|
||||
API_URL=https://api.stream.graff.tech
|
||||
SERVER_TYPE=stream
|
||||
SERVER_LOCATION=ru1
|
||||
SERVER_TIER=demo
|
||||
REGISTER_INTERVAL_MS=30000
|
||||
```
|
||||
|
||||
### Stream сервер в ОАЭ (продакшн)
|
||||
|
||||
```env
|
||||
API_URL=https://api.stream.graff.tech
|
||||
SERVER_TYPE=stream
|
||||
SERVER_LOCATION=uae1
|
||||
SERVER_TIER=prod
|
||||
REGISTER_INTERVAL_MS=30000
|
||||
```
|
||||
|
||||
### Local сервер для филиала
|
||||
|
||||
```env
|
||||
API_URL=https://api.stream.graff.tech
|
||||
SERVER_TYPE=local
|
||||
BRANCH_ID=123e4567-e89b-12d3-a456-426614174000
|
||||
REGISTER_INTERVAL_MS=60000
|
||||
```
|
||||
|
||||
## Логирование
|
||||
|
||||
Сервер выводит подробную информацию о своей работе:
|
||||
|
||||
```text
|
||||
============================================================
|
||||
🚀 Запуск сессионного сервера
|
||||
============================================================
|
||||
Конфигурация:
|
||||
API URL: http://localhost:3000
|
||||
Hostname: DESKTOP-ABC123
|
||||
Local IP: 192.168.1.100
|
||||
Type: stream
|
||||
GPU Free MB: 8192 (читается из nvidia-smi)
|
||||
Location: ru1
|
||||
Tier: demo
|
||||
Register Interval: 30000ms
|
||||
GPU Update Interval: 1000ms
|
||||
============================================================
|
||||
[2025-10-06T12:00:00.000Z] Регистрация сервера...
|
||||
Данные: {
|
||||
"localIp": "192.168.1.100",
|
||||
"hostname": "DESKTOP-ABC123",
|
||||
"type": "stream",
|
||||
"gpuFreeMb": 8192,
|
||||
"location": "ru1",
|
||||
"tier": "demo"
|
||||
}
|
||||
[2025-10-06T12:00:00.123Z] ✅ Сервер успешно зарегистрирован
|
||||
ID сервера: 123e4567-e89b-12d3-a456-426614174000
|
||||
[2025-10-06T12:00:01.000Z] 🎮 GPU память обновлена: 8150 MB
|
||||
[2025-10-06T12:00:02.000Z] 🎮 GPU память обновлена: 8120 MB
|
||||
[2025-10-06T12:00:03.000Z] 🎮 GPU память обновлена: 8100 MB
|
||||
```
|
||||
|
||||
## Обработка ошибок
|
||||
|
||||
Сервер автоматически обрабатывает ошибки:
|
||||
|
||||
- **Валидация конфигурации**:
|
||||
- Stream-серверы: обязательно должен быть указан `SERVER_LOCATION`
|
||||
- Local-серверы: обязательно должен быть указан `BRANCH_ID`
|
||||
- При отсутствии обязательных полей сервер завершит работу с критической ошибкой
|
||||
- **Сетевые ошибки**: Автоматические повторные попытки (до 3 раз)
|
||||
- **Таймауты**: 10 секунд на запрос
|
||||
- **Коды ошибок**: Повторная отправка при временных ошибках сервера (408, 429, 500, 502, 503, 504)
|
||||
- **Ошибки GPU**: Если `nvidia-smi` недоступен или возвращает некорректные данные, сервер завершит работу с критической ошибкой
|
||||
- **Критические ошибки**: Подробное логирование с завершением работы
|
||||
|
||||
## Архитектура
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────┐
|
||||
│ Session Server │
|
||||
│ │
|
||||
│ Регистрация (рекурсивно): │
|
||||
│ 1. Определение IP │
|
||||
│ 2. Определение hostname │
|
||||
│ 3. Запрос GPU (nvidia-smi) │
|
||||
│ 4. POST /servers/register │
|
||||
│ 5. setTimeout → повтор (30s) │
|
||||
│ │
|
||||
│ Обновление GPU (рекурсивно): │
|
||||
│ 1. Запрос GPU (nvidia-smi) │
|
||||
│ 2. PATCH /servers/:id/gpu │
|
||||
│ 3. setTimeout → повтор (1s) │
|
||||
└──────────┬───────────────────────┘
|
||||
│
|
||||
│ HTTP Requests
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────┐
|
||||
│ Main API Server │
|
||||
│ │
|
||||
│ 1. Проверка по hostname │
|
||||
│ 2. Создание/обновление │
|
||||
│ 3. Обновление GPU памяти │
|
||||
│ 4. Возврат данных │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Разработка
|
||||
|
||||
### Структура проекта
|
||||
|
||||
```text
|
||||
session-server/
|
||||
├── src/
|
||||
│ └── index.ts # Основной файл приложения
|
||||
├── dist/ # Собранное приложение
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── bun.build.ts # Конфигурация сборки
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Требования
|
||||
|
||||
- Bun >= 1.0
|
||||
- Node.js >= 18 (опционально)
|
||||
- TypeScript >= 5.0
|
||||
- **NVIDIA GPU с установленным `nvidia-smi`** (обязательно)
|
||||
|
||||
## Лицензия
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,32 @@
|
||||
import { $, Glob } from "bun";
|
||||
import path from "path";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
// Cross-platform directory removal
|
||||
if (process.platform === "win32") {
|
||||
// await $`cmd /c "rmdir /s /q dist"`;
|
||||
} else {
|
||||
await $`rm -rf ./dist`;
|
||||
}
|
||||
|
||||
// await Bun.build({
|
||||
// entrypoints: ["./src/index.ts"],
|
||||
// env: "inline",
|
||||
// target: "bun",
|
||||
// outdir: `./dist`,
|
||||
// minify: true,
|
||||
// });
|
||||
|
||||
// Build all files in src
|
||||
for (const entrypoint of new Glob("./src/**/*.ts").scanSync()) {
|
||||
const parts = entrypoint.split(path.sep);
|
||||
const entrypointPath = path.join(...parts.slice(2, -1));
|
||||
|
||||
await Bun.build({
|
||||
entrypoints: [entrypoint],
|
||||
target: "bun",
|
||||
outdir: path.join("dist", entrypointPath),
|
||||
env: "inline",
|
||||
minify: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "strem.graff.tech-session-server",
|
||||
"dependencies": {
|
||||
"got": "^14.4.9",
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
|
||||
|
||||
"@sindresorhus/is": ["@sindresorhus/is@7.1.0", "", {}, "sha512-7F/yz2IphV39hiS2zB4QYVkivrptHHh0K8qJJd9HhuWSdvf8AN7NpebW3CcDZDBQsUPMoDKWsY2WWgW7bqOcfA=="],
|
||||
|
||||
"@szmarczak/http-timer": ["@szmarczak/http-timer@5.0.1", "", { "dependencies": { "defer-to-connect": "^2.0.1" } }, "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw=="],
|
||||
|
||||
"@types/http-cache-semantics": ["@types/http-cache-semantics@4.0.4", "", {}, "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA=="],
|
||||
|
||||
"@types/node": ["@types/node@24.7.0", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="],
|
||||
|
||||
"cacheable-lookup": ["cacheable-lookup@7.0.0", "", {}, "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w=="],
|
||||
|
||||
"cacheable-request": ["cacheable-request@12.0.1", "", { "dependencies": { "@types/http-cache-semantics": "^4.0.4", "get-stream": "^9.0.1", "http-cache-semantics": "^4.1.1", "keyv": "^4.5.4", "mimic-response": "^4.0.0", "normalize-url": "^8.0.1", "responselike": "^3.0.0" } }, "sha512-Yo9wGIQUaAfIbk+qY0X4cDQgCosecfBe3V9NSyeY4qPC2SAkbCS4Xj79VP8WOzitpJUZKc/wsRCYF5ariDIwkg=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||
|
||||
"defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
|
||||
|
||||
"form-data-encoder": ["form-data-encoder@4.1.0", "", {}, "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw=="],
|
||||
|
||||
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
|
||||
|
||||
"got": ["got@14.4.9", "", { "dependencies": { "@sindresorhus/is": "^7.0.1", "@szmarczak/http-timer": "^5.0.1", "cacheable-lookup": "^7.0.0", "cacheable-request": "^12.0.1", "decompress-response": "^6.0.0", "form-data-encoder": "^4.0.2", "http2-wrapper": "^2.2.1", "lowercase-keys": "^3.0.0", "p-cancelable": "^4.0.1", "responselike": "^3.0.0", "type-fest": "^4.26.1" } }, "sha512-Dbu075Jwm3QwNCIoCenqkqY8l2gd7e/TanuhMbzZIEsb1mpAneImSusKhZ+XdqqC3S91SDV/1SdWpGXKAlm8tA=="],
|
||||
|
||||
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
|
||||
|
||||
"http2-wrapper": ["http2-wrapper@2.2.1", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.2.0" } }, "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ=="],
|
||||
|
||||
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"lowercase-keys": ["lowercase-keys@3.0.0", "", {}, "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ=="],
|
||||
|
||||
"mimic-response": ["mimic-response@4.0.0", "", {}, "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg=="],
|
||||
|
||||
"normalize-url": ["normalize-url@8.1.0", "", {}, "sha512-X06Mfd/5aKsRHc0O0J5CUedwnPmnDtLF2+nq+KN9KSDlJHkPuh0JUviWjEWMe0SW/9TDdSLVPuk7L5gGTIA1/w=="],
|
||||
|
||||
"p-cancelable": ["p-cancelable@4.0.1", "", {}, "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg=="],
|
||||
|
||||
"quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],
|
||||
|
||||
"resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="],
|
||||
|
||||
"responselike": ["responselike@3.0.0", "", { "dependencies": { "lowercase-keys": "^3.0.0" } }, "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg=="],
|
||||
|
||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
|
||||
|
||||
"decompress-response/mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "strem.graff.tech-session-server",
|
||||
"version": "1.0.50",
|
||||
"module": "src/index.js",
|
||||
"devDependencies": {
|
||||
"bun-types": "latest"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"build": "bun bun.build.ts",
|
||||
"start": "bun ./dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"got": "^14.4.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
import got, { RequestError } from "got";
|
||||
import os from "os";
|
||||
import { execSync, spawn, ChildProcess } from "child_process";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
// Конфигурация
|
||||
const API_URL = process.env.API_URL || "http://localhost:3000";
|
||||
const SERVER_TYPE = (process.env.SERVER_TYPE || "stream") as "stream" | "local";
|
||||
const SERVER_LOCATION =
|
||||
(process.env.SERVER_LOCATION as "ru1" | "uae1" | undefined) || undefined;
|
||||
const SERVER_TIER =
|
||||
(process.env.SERVER_TIER as "demo" | "prod" | undefined) || undefined;
|
||||
const BRANCH_ID = process.env.BRANCH_ID || undefined;
|
||||
const LOCAL_IP = getLocalIp();
|
||||
const HOSTNAME = os.hostname();
|
||||
const REGISTER_INTERVAL_MS = parseInt(
|
||||
process.env.REGISTER_INTERVAL_MS || "30000",
|
||||
10
|
||||
); // 30 секунд по умолчанию
|
||||
const GPU_UPDATE_INTERVAL_MS = parseInt(
|
||||
process.env.GPU_UPDATE_INTERVAL_MS || "1000",
|
||||
10
|
||||
); // 1 секунда по умолчанию
|
||||
const SESSION_CHECK_INTERVAL_MS = parseInt(
|
||||
process.env.SESSION_CHECK_INTERVAL_MS || "1000",
|
||||
10
|
||||
); // 1 секунда по умолчанию
|
||||
|
||||
// ID зарегистрированного сервера (заполняется после регистрации)
|
||||
let SERVER_ID: string | null = null;
|
||||
|
||||
// Карта активных процессов: sessionId -> процесс приложения
|
||||
const activeProcesses = new Map<string, ChildProcess>();
|
||||
|
||||
// Карта сессий в процессе запуска/остановки для предотвращения дублирования
|
||||
const processingSessions = new Set<string>();
|
||||
|
||||
// Карта последнего времени логирования запланированных сессий (для уменьшения спама в логах)
|
||||
const lastScheduledLogTime = new Map<string, number>();
|
||||
|
||||
interface ServerRegistrationData {
|
||||
localIp: string;
|
||||
hostname: string;
|
||||
type: "stream" | "local";
|
||||
gpuFreeMb: number;
|
||||
branchId?: string;
|
||||
location?: "ru1" | "uae1";
|
||||
tier?: "demo" | "prod";
|
||||
}
|
||||
|
||||
interface ServerRegistrationResponse {
|
||||
server: {
|
||||
id: string;
|
||||
localIp: string;
|
||||
hostname: string;
|
||||
type: "stream" | "local";
|
||||
gpuFreeMb: number;
|
||||
branchId?: string;
|
||||
location?: "ru1" | "uae1";
|
||||
tier?: "demo" | "prod";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
registered: boolean;
|
||||
}
|
||||
|
||||
interface SessionData {
|
||||
id: string;
|
||||
serverId: string | null; // Nullable - для stream сессий назначается динамически
|
||||
appId: string;
|
||||
userId: string;
|
||||
startAt: string;
|
||||
endAt: string | null;
|
||||
appPid: number | null;
|
||||
cirrusPid: number | null;
|
||||
mode: "stream" | "local";
|
||||
status: "starting" | "started" | "ending" | "ended";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
app: {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
gpuLimitMb: number | null;
|
||||
psVersion: number | null;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить локальный IP адрес
|
||||
*/
|
||||
function getLocalIp(): string {
|
||||
const interfaces = os.networkInterfaces();
|
||||
|
||||
for (const name of Object.keys(interfaces)) {
|
||||
const netInterface = interfaces[name];
|
||||
if (!netInterface) continue;
|
||||
|
||||
for (const iface of netInterface) {
|
||||
// Пропустить внутренние и non-IPv4 адреса
|
||||
if (iface.family === "IPv4" && !iface.internal) {
|
||||
return iface.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "127.0.0.1";
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить свободную память GPU через nvidia-smi
|
||||
* Возвращает количество свободной памяти в МБ
|
||||
* Выбрасывает ошибку, если не удалось получить данные
|
||||
*/
|
||||
function getGpuFreeMb(): number {
|
||||
try {
|
||||
// Выполняем nvidia-smi с форматом вывода только свободной памяти
|
||||
// --query-gpu=memory.free - запрашиваем свободную память
|
||||
// --format=csv,noheader,nounits - CSV формат без заголовков и единиц измерения
|
||||
const output = execSync(
|
||||
"nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits",
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 5000, // 5 секунд таймаут
|
||||
}
|
||||
);
|
||||
|
||||
// Парсим вывод (может быть несколько GPU, берём первый)
|
||||
const lines = output.trim().split("\n");
|
||||
if (lines.length > 0 && lines[0]) {
|
||||
const freeMb = parseInt(lines[0].trim(), 10);
|
||||
if (!isNaN(freeMb) && freeMb >= 0) {
|
||||
return freeMb;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Не удалось распарсить вывод nvidia-smi");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Критическая ошибка при получении данных GPU:`,
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
throw new Error(
|
||||
`Невозможно получить данные о GPU через nvidia-smi: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Регистрация сервера на главном сервере
|
||||
*/
|
||||
async function registerServer(isRecursive: boolean = false): Promise<void> {
|
||||
// Валидация для stream-серверов
|
||||
if (SERVER_TYPE === "stream" && !SERVER_LOCATION) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка: для stream-серверов обязательно должен быть указан SERVER_LOCATION`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Валидация для local-серверов
|
||||
if (SERVER_TYPE === "local" && !BRANCH_ID) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка: для local-серверов обязательно должен быть указан BRANCH_ID`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Установить tier по умолчанию для stream-серверов
|
||||
const tier = SERVER_TYPE === "stream" && !SERVER_TIER ? "demo" : SERVER_TIER;
|
||||
|
||||
// Получаем актуальное значение свободной GPU памяти
|
||||
const gpuFreeMb = getGpuFreeMb();
|
||||
|
||||
const registrationData: ServerRegistrationData = {
|
||||
localIp: LOCAL_IP,
|
||||
hostname: HOSTNAME,
|
||||
type: SERVER_TYPE,
|
||||
gpuFreeMb: gpuFreeMb,
|
||||
branchId: BRANCH_ID,
|
||||
location: SERVER_LOCATION,
|
||||
tier: tier,
|
||||
};
|
||||
|
||||
console.log(`[${new Date().toISOString()}] Регистрация сервера...`);
|
||||
console.log("Данные:", JSON.stringify(registrationData, null, 2));
|
||||
|
||||
try {
|
||||
const response = await got
|
||||
.post(`${API_URL}/servers/register`, {
|
||||
json: registrationData,
|
||||
timeout: {
|
||||
request: 10000, // 10 секунд таймаут
|
||||
},
|
||||
retry: {
|
||||
limit: 3,
|
||||
methods: ["POST"],
|
||||
statusCodes: [408, 413, 429, 500, 502, 503, 504],
|
||||
},
|
||||
})
|
||||
.json<ServerRegistrationResponse>();
|
||||
|
||||
// Сохраняем ID сервера для дальнейших обновлений
|
||||
SERVER_ID = response.server.id;
|
||||
|
||||
if (response.registered) {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ✅ Сервер успешно зарегистрирован`
|
||||
);
|
||||
console.log("ID сервера:", response.server.id);
|
||||
} else {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 🔄 Информация о сервере обновлена`
|
||||
);
|
||||
}
|
||||
|
||||
// При первом запуске запускаем обновление GPU и проверку сессий после успешной регистрации
|
||||
if (!isRecursive && SERVER_ID) {
|
||||
updateGpuMemory();
|
||||
checkSessions();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка регистрации сервера:`,
|
||||
error
|
||||
);
|
||||
|
||||
if (error instanceof RequestError) {
|
||||
console.error("Детали ошибки:", {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
});
|
||||
} else if (error instanceof Error) {
|
||||
console.error("Ошибка:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Планируем следующую регистрацию после завершения текущей
|
||||
setTimeout(() => registerServer(true), REGISTER_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить информацию о свободной памяти GPU на сервере
|
||||
*/
|
||||
async function updateGpuMemory(): Promise<void> {
|
||||
try {
|
||||
// Получаем актуальное значение свободной GPU памяти
|
||||
const gpuFreeMb = getGpuFreeMb();
|
||||
|
||||
await got.patch(`${API_URL}/servers/${SERVER_ID}/gpu`, {
|
||||
json: { gpuFreeMb },
|
||||
timeout: {
|
||||
request: 5000, // 5 секунд таймаут
|
||||
},
|
||||
retry: {
|
||||
limit: 2,
|
||||
methods: ["PATCH"],
|
||||
statusCodes: [408, 429, 500, 502, 503, 504],
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 🎮 GPU память обновлена: ${gpuFreeMb} MB`
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка обновления GPU памяти:`,
|
||||
error instanceof RequestError ? error.message : error
|
||||
);
|
||||
}
|
||||
|
||||
// Планируем следующее обновление после завершения текущего
|
||||
setTimeout(() => updateGpuMemory(), GPU_UPDATE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить сессии для этого сервера
|
||||
*/
|
||||
async function fetchSessions(): Promise<SessionData[]> {
|
||||
if (!SERVER_ID) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await got
|
||||
.get(`${API_URL}/servers/${SERVER_ID}/sessions`, {
|
||||
timeout: {
|
||||
request: 5000,
|
||||
},
|
||||
})
|
||||
.json<{ sessions: SessionData[] }>();
|
||||
|
||||
return response.sessions;
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка получения сессий:`,
|
||||
error instanceof RequestError ? error.message : error
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить статус сессии на главном сервере
|
||||
*/
|
||||
async function updateSessionStatus(
|
||||
sessionId: string,
|
||||
status: "starting" | "started" | "ending" | "ended",
|
||||
appPid?: number,
|
||||
cirrusPid?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
await got.patch(`${API_URL}/sessions/${sessionId}/status`, {
|
||||
json: {
|
||||
status,
|
||||
appPid,
|
||||
cirrusPid,
|
||||
},
|
||||
timeout: {
|
||||
request: 5000,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ✅ Статус сессии ${sessionId} обновлен на "${status}"`
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка обновления статуса сессии:`,
|
||||
error instanceof RequestError ? error.message : error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запустить приложение для сессии
|
||||
*/
|
||||
async function startApplication(session: SessionData): Promise<void> {
|
||||
const { id: sessionId, app, serverId } = session;
|
||||
|
||||
// Проверить, не обрабатывается ли уже эта сессия
|
||||
if (processingSessions.has(sessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверить, не запущено ли уже приложение для этой сессии
|
||||
if (activeProcesses.has(sessionId)) {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ⚠️ Приложение для сессии ${sessionId} уже запущено`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверить, что сервер назначен
|
||||
// Main server автоматически назначает серверы для готовых к запуску сессий
|
||||
if (!serverId) {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ⏳ Сессия ${sessionId} ожидает назначения сервера main сервером`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверить, что сессия назначена именно этому серверу
|
||||
if (serverId !== SERVER_ID) {
|
||||
// Это нормально - сессия назначена другому серверу
|
||||
return;
|
||||
}
|
||||
|
||||
processingSessions.add(sessionId);
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 🚀 Запуск приложения "${app.name}" для сессии ${sessionId} (активных процессов: ${activeProcesses.size})`
|
||||
);
|
||||
|
||||
// Формируем путь к exe файлу приложения
|
||||
// Путь: C:\apps\{appName}\{appName}.exe
|
||||
const appPath = `C:\\apps\\${app.name}\\${app.name}.exe`;
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 📂 Путь к приложению: ${appPath}`
|
||||
);
|
||||
|
||||
// Проверяем существование exe файла
|
||||
if (!existsSync(appPath)) {
|
||||
throw new Error(
|
||||
`Файл приложения не найден: ${appPath}. Убедитесь, что приложение установлено.`
|
||||
);
|
||||
}
|
||||
|
||||
// Запускаем exe приложение
|
||||
// Используем 'pipe' для stderr чтобы видеть ошибки, но 'ignore' для stdin/stdout
|
||||
const appProcess = spawn(appPath, [], {
|
||||
detached: false,
|
||||
stdio: ["ignore", "ignore", "pipe"], // stdin: ignore, stdout: ignore, stderr: pipe
|
||||
windowsHide: true, // Скрывать окно консоли на Windows
|
||||
cwd: `C:\\apps\\${app.name}`, // Устанавливаем рабочую директорию приложения
|
||||
});
|
||||
|
||||
const appPid = appProcess.pid;
|
||||
|
||||
if (!appPid) {
|
||||
throw new Error("Не удалось получить PID процесса");
|
||||
}
|
||||
|
||||
// Сохранить процесс в карте активных процессов
|
||||
activeProcesses.set(sessionId, appProcess);
|
||||
|
||||
// Логирование stderr для диагностики
|
||||
if (appProcess.stderr) {
|
||||
appProcess.stderr.on("data", (data) => {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] 🔴 STDERR [${sessionId}]: ${data.toString().trim()}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Обработка завершения процесса
|
||||
appProcess.on("exit", async (code, signal) => {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 🛑 Приложение для сессии ${sessionId} завершилось с кодом ${code}${signal ? ` (сигнал: ${signal})` : ""}`
|
||||
);
|
||||
|
||||
if (code !== 0 && code !== null) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ⚠️ Приложение завершилось с ошибкой! Код выхода: ${code}`
|
||||
);
|
||||
}
|
||||
|
||||
activeProcesses.delete(sessionId);
|
||||
|
||||
// Обновить статус на "ended"
|
||||
await updateSessionStatus(sessionId, "ended");
|
||||
});
|
||||
|
||||
appProcess.on("error", async (error) => {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка процесса для сессии ${sessionId}:`,
|
||||
error
|
||||
);
|
||||
activeProcesses.delete(sessionId);
|
||||
|
||||
// Обновить статус на "ended" в случае ошибки
|
||||
await updateSessionStatus(sessionId, "ended");
|
||||
});
|
||||
|
||||
// Обновить статус на "started" с PID
|
||||
await updateSessionStatus(sessionId, "started", appPid);
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ✅ Приложение запущено с PID ${appPid} (всего активных: ${activeProcesses.size})`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка запуска приложения:`,
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
// Обновить статус на "ended" в случае ошибки
|
||||
await updateSessionStatus(sessionId, "ended");
|
||||
} finally {
|
||||
processingSessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Убить процесс и всё его дерево дочерних процессов (для Windows)
|
||||
*/
|
||||
function killProcessTree(pid: number): void {
|
||||
try {
|
||||
// На Windows используем taskkill с флагом /T для убийства дерева процессов
|
||||
// /F - принудительное завершение
|
||||
// /T - завершить указанный процесс и все дочерние процессы
|
||||
execSync(`taskkill /pid ${pid} /T /F`, {
|
||||
stdio: "ignore",
|
||||
timeout: 10000,
|
||||
});
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ✅ Дерево процессов для PID ${pid} успешно завершено`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ⚠️ Ошибка при завершении дерева процессов PID ${pid}:`,
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Остановить приложение для сессии
|
||||
*/
|
||||
async function stopApplication(session: SessionData): Promise<void> {
|
||||
const { id: sessionId, appPid } = session;
|
||||
|
||||
// Проверить, не обрабатывается ли уже эта сессия
|
||||
if (processingSessions.has(sessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const appProcess = activeProcesses.get(sessionId);
|
||||
|
||||
if (!appProcess && !appPid) {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ⚠️ Приложение для сессии ${sessionId} не найдено в активных процессах`
|
||||
);
|
||||
// Всё равно обновляем статус на "ended"
|
||||
await updateSessionStatus(sessionId, "ended");
|
||||
return;
|
||||
}
|
||||
|
||||
processingSessions.add(sessionId);
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 🛑 Остановка приложения для сессии ${sessionId} (PID: ${appPid || appProcess?.pid || "неизвестен"})`
|
||||
);
|
||||
|
||||
// Используем PID из базы данных если он есть, иначе из процесса
|
||||
const pidToKill = appPid || appProcess?.pid;
|
||||
|
||||
if (pidToKill) {
|
||||
// Убиваем весь процесс и все его дочерние процессы
|
||||
killProcessTree(pidToKill);
|
||||
} else if (appProcess) {
|
||||
// Если по какой-то причине нет PID, пробуем стандартный способ
|
||||
appProcess.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
if (!appProcess.killed) {
|
||||
appProcess.kill("SIGKILL");
|
||||
}
|
||||
}
|
||||
|
||||
activeProcesses.delete(sessionId);
|
||||
|
||||
// Обновить статус на "ended"
|
||||
await updateSessionStatus(sessionId, "ended");
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ✅ Приложение и все дочерние процессы остановлены для сессии ${sessionId}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка остановки приложения:`,
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
} finally {
|
||||
processingSessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить и обработать сессии
|
||||
*/
|
||||
async function checkSessions(): Promise<void> {
|
||||
if (!SERVER_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessions = await fetchSessions();
|
||||
// Получаем текущее время в UTC
|
||||
const now = new Date();
|
||||
|
||||
// Обработать сессии со статусом "starting"
|
||||
// Запускать только если время startAt уже наступило
|
||||
// Примечание: PostgreSQL возвращает timestamp with timezone как ISO 8601 строку,
|
||||
// которая автоматически парсится в UTC при создании Date объекта
|
||||
const allStartingSessions = sessions.filter((s) => s.status === "starting");
|
||||
const startingSessions = allStartingSessions.filter((s) => {
|
||||
const startAt = new Date(s.startAt);
|
||||
return startAt <= now;
|
||||
});
|
||||
|
||||
// Логировать запланированные сессии, которые ещё не пришло время запускать
|
||||
// Логируем не чаще раза в 10 секунд для каждой сессии, чтобы не спамить логами
|
||||
const scheduledSessions = allStartingSessions.filter((s) => {
|
||||
const startAt = new Date(s.startAt);
|
||||
return startAt > now;
|
||||
});
|
||||
|
||||
if (scheduledSessions.length > 0) {
|
||||
for (const session of scheduledSessions) {
|
||||
const lastLogTime = lastScheduledLogTime.get(session.id) || 0;
|
||||
const timeSinceLastLog = now.getTime() - lastLogTime;
|
||||
|
||||
// Логируем только если прошло больше 10 секунд с последнего лога
|
||||
if (timeSinceLastLog > 10000) {
|
||||
const startAt = new Date(session.startAt);
|
||||
const timeUntilStart = Math.round((startAt.getTime() - now.getTime()) / 1000);
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ⏰ Сессия ${session.id} (${session.app.name}) запланирована через ${timeUntilStart} сек`
|
||||
);
|
||||
lastScheduledLogTime.set(session.id, now.getTime());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Очистить карту логирования для сессий, которые больше не запланированы
|
||||
const scheduledSessionIds = new Set(scheduledSessions.map((s) => s.id));
|
||||
for (const sessionId of lastScheduledLogTime.keys()) {
|
||||
if (!scheduledSessionIds.has(sessionId)) {
|
||||
lastScheduledLogTime.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const session of startingSessions) {
|
||||
await startApplication(session);
|
||||
}
|
||||
|
||||
// Обработать сессии со статусом "ending"
|
||||
const endingSessions = sessions.filter((s) => s.status === "ending");
|
||||
for (const session of endingSessions) {
|
||||
await stopApplication(session);
|
||||
}
|
||||
|
||||
// Проверить, что все активные процессы соответствуют активным сессиям
|
||||
// Сессия считается активной, если:
|
||||
// 1. Статус "started"
|
||||
// 2. Статус "starting" И время startAt уже наступило
|
||||
const activeSessions = sessions.filter((s) => {
|
||||
if (s.status === "started") return true;
|
||||
if (s.status === "starting") {
|
||||
const startAt = new Date(s.startAt);
|
||||
return startAt <= now;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const activeSessionIds = new Set(activeSessions.map((s) => s.id));
|
||||
|
||||
// Остановить процессы для сессий, которые больше не активны
|
||||
for (const [sessionId, process] of activeProcesses.entries()) {
|
||||
if (!activeSessionIds.has(sessionId)) {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ⚠️ Найден процесс для неактивной сессии ${sessionId}, остановка`
|
||||
);
|
||||
process.kill("SIGTERM");
|
||||
activeProcesses.delete(sessionId);
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка проверки сессий:`,
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
}
|
||||
|
||||
// Планируем следующую проверку
|
||||
setTimeout(() => checkSessions(), SESSION_CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Основная функция
|
||||
*/
|
||||
async function main() {
|
||||
console.log("=".repeat(60));
|
||||
console.log("🚀 Запуск сессионного сервера");
|
||||
console.log("=".repeat(60));
|
||||
console.log("Конфигурация:");
|
||||
console.log(` API URL: ${API_URL}`);
|
||||
console.log(` Hostname: ${HOSTNAME}`);
|
||||
console.log(` Local IP: ${LOCAL_IP}`);
|
||||
console.log(` Type: ${SERVER_TYPE}`);
|
||||
console.log(` GPU Free MB: ${getGpuFreeMb()} (читается из nvidia-smi)`);
|
||||
if (SERVER_LOCATION) console.log(` Location: ${SERVER_LOCATION}`);
|
||||
if (SERVER_TIER) console.log(` Tier: ${SERVER_TIER}`);
|
||||
if (BRANCH_ID) console.log(` Branch ID: ${BRANCH_ID}`);
|
||||
console.log(` Register Interval: ${REGISTER_INTERVAL_MS}ms`);
|
||||
console.log(` GPU Update Interval: ${GPU_UPDATE_INTERVAL_MS}ms`);
|
||||
console.log(` Session Check Interval: ${SESSION_CHECK_INTERVAL_MS}ms`);
|
||||
console.log("=".repeat(60));
|
||||
|
||||
// Запуск рекурсивной регистрации
|
||||
// Использует setTimeout вместо setInterval, чтобы избежать наложения запросов
|
||||
// После первой успешной регистрации запускается обновление GPU
|
||||
await registerServer();
|
||||
}
|
||||
|
||||
// Запуск
|
||||
main().catch((error: unknown) => {
|
||||
console.error(
|
||||
"Критическая ошибка:",
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "ES2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "ES2022", /* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
"moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
"types": ["bun-types"], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user