diff --git a/AGENTS.md b/AGENTS.md index 7825bf9..3292d37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,3 +23,20 @@ - Если из запроса неясно, куда деплоить, обязательно сначала спросить пользователя, какой именно целевой контур нужен. - По умолчанию сначала деплой и проверка на тестовом контуре; на основной (`prod`) деплоить только после явного подтверждения пользователя, что версия проверена и готова. - При уточняющем вопросе отдельно предупреждать: деплой на основной выполнять только если точно подтверждена корректная работа. + +## Логи звонков (установка соединения) +- Специальный поток диагностики установки звонков идёт через `CallDeliveryReport` (клиент → сервер). +- На проде специальный файл для звонков: +- `/home/player/SHiNE/shine-server/logs/call-delivery-events.log` +- Общий серверный лог (и ротации) на проде: +- `/home/player/SHiNE/shine-server/logs/app.log` +- `/home/player/SHiNE/shine-server/logs/app.YYYY-MM-DD.log` +- Для анализа причин недозвона в первую очередь фильтровать записи по ключам: +- `CallDeliveryReport` +- `call_connected` +- `outgoing_failed` +- `incoming_failed` +- `call_busy` +- `call_declined` +- `unknown_error` +- В этих записях искать поля `reason`, `failureStage`, `pcConnectionState`, `pcIceConnectionState`, `routeLabel`, `configuredTurnHosts*`, `reachableTurnHosts*`. diff --git a/VERSION.properties b/VERSION.properties index 7c10852..370bc3c 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.39 -server.version=1.2.33 +client.version=1.2.40 +server.version=1.2.34 diff --git a/shine-UI/js/pages/server-settings-view.js b/shine-UI/js/pages/server-settings-view.js index c1ad4e8..315a7bb 100644 --- a/shine-UI/js/pages/server-settings-view.js +++ b/shine-UI/js/pages/server-settings-view.js @@ -19,6 +19,7 @@ export function render({ navigate }) { solanaServer: state.entrySettings.solanaServer, shineServer: state.entrySettings.shineServer, arweaveServer: state.entrySettings.arweaveServer, + callPreflightTimeoutMs: Number(state.entrySettings.callPreflightTimeoutMs || 6000), statuses: { ...state.entrySettings.statuses }, }; @@ -108,6 +109,33 @@ export function render({ navigate }) { body.append(block); }); + const callSettings = document.createElement('div'); + callSettings.className = 'stack'; + + const callTimeoutLabel = document.createElement('label'); + callTimeoutLabel.className = 'field-label'; + callTimeoutLabel.textContent = 'Таймаут пред-подключения перед звонком (мс)'; + + const callTimeoutInput = document.createElement('input'); + callTimeoutInput.className = 'input'; + callTimeoutInput.type = 'number'; + callTimeoutInput.min = '1000'; + callTimeoutInput.max = '20000'; + callTimeoutInput.step = '500'; + callTimeoutInput.value = String(Math.max(1000, Math.min(20000, Number(draft.callPreflightTimeoutMs) || 6000))); + callTimeoutInput.addEventListener('input', () => { + const n = Number(callTimeoutInput.value); + if (!Number.isFinite(n)) return; + draft.callPreflightTimeoutMs = Math.max(1000, Math.min(20000, Math.round(n))); + }); + + const callTimeoutHint = document.createElement('p'); + callTimeoutHint.className = 'meta-muted'; + callTimeoutHint.textContent = 'Перед исходящим звонком клиент проверяет и восстанавливает WS-сессию. Это время ожидания такой проверки перед ошибкой «Сервер временно недоступен».'; + + callSettings.append(callTimeoutLabel, callTimeoutInput, callTimeoutHint); + body.append(callSettings); + const actions = document.createElement('div'); actions.className = 'auth-footer-actions'; diff --git a/shine-UI/js/services/call-service.js b/shine-UI/js/services/call-service.js index 83636f8..da018ff 100644 --- a/shine-UI/js/services/call-service.js +++ b/shine-UI/js/services/call-service.js @@ -1,4 +1,4 @@ -import { addSystemChatMessage, authService } from '../state.js'; +import { addSystemChatMessage, authService, authorizeSession, state } from '../state.js'; const TYPES = { INVITE: 100, @@ -31,6 +31,47 @@ function nowMs() { return Date.now(); } +function resolveCallPreflightTimeoutMs() { + const configured = Number(state?.entrySettings?.callPreflightTimeoutMs || 6000); + return Math.max(1000, Math.min(20000, Number.isFinite(configured) ? configured : 6000)); +} + +function isSessionReadyForCall() { + const wsOpen = Boolean(authService?.ws?.ws && authService.ws.ws.readyState === WebSocket.OPEN); + const hasSession = Boolean(state?.session?.isAuthorized && state?.session?.login && state?.session?.sessionId); + return wsOpen && hasSession; +} + +async function withTimeout(promise, timeoutMs, timeoutMessage = 'timeout') { + let timerId = 0; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timerId = window.setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); + }), + ]); + } finally { + if (timerId) window.clearTimeout(timerId); + } +} + +async function ensureSessionForCall({ timeoutMs, force = false } = {}) { + if (!force && isSessionReadyForCall()) return true; + const login = String(state?.session?.login || '').trim(); + const sessionId = String(state?.session?.sessionId || '').trim(); + if (!login || !sessionId) return false; + + try { + await withTimeout(authService.ws.open(), timeoutMs, 'call_preflight_ws_timeout'); + const resumed = await withTimeout(authService.resumeSession(login, sessionId), timeoutMs, 'call_preflight_resume_timeout'); + authorizeSession(resumed); + return true; + } catch { + return false; + } +} + function makeCallId() { return `${Date.now()}${Math.floor(Math.random() * 1_000_000_000)}`; } @@ -1314,6 +1355,12 @@ export async function startOutgoingCall(peerLogin) { } const callId = makeCallId(); + const preflightTimeoutMs = resolveCallPreflightTimeoutMs(); + const preflightOk = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: false }); + if (!preflightOk) { + throw new Error('Сервер временно недоступен'); + } + const call = { callId, peerLogin: cleanPeer, @@ -1359,6 +1406,19 @@ export async function startOutgoingCall(peerLogin) { try { await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE }); } catch (error) { + const text = String(error?.message || '').toUpperCase(); + const isNotAuth = text.includes('NOT_AUTHENTICATED'); + if (isNotAuth) { + const recovered = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: true }); + if (recovered) { + try { + await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE }); + return; + } catch {} + } + await finalizeCall(call, { localReasonCode: 'error', debugReason: 'invite_failed:not_authenticated_after_retry' }); + throw new Error('Сервер временно недоступен'); + } await finalizeCall(call, { localReasonCode: 'error', debugReason: `invite_failed:${toErrorText(error)}` }); throw error; } @@ -1587,6 +1647,11 @@ export async function handleCallPushAction(action, payload = {}) { const normalized = String(action || '').trim().toLowerCase(); if (normalized !== 'accept' && normalized !== 'decline') return; if (!isIncomingCallPushFresh(payload)) return; + const timeoutMs = resolveCallPreflightTimeoutMs(); + const ok = await ensureSessionForCall({ timeoutMs, force: false }); + if (!ok) { + throw new Error('Не удалось подключиться, вызов завершён'); + } await handleIncomingCallPush(payload); if (normalized === 'accept') { await acceptIncomingCall(); diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index 4e4b6fa..1857cf8 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -80,6 +80,7 @@ const LOCAL_WS_OVERRIDE_URL = readLocalWsOverrideUrl() || inferTunnelWsUrl(); const DEFAULT_SOLANA_SERVER = 'https://api.devnet.solana.com'; const DEFAULT_SHINE_SERVER = 'wss://shineup.me/ws'; const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net'; +const DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS = 6000; function loadStoredSession() { try { @@ -146,6 +147,7 @@ function persistEntrySettings(settings) { solanaServer: String(settings?.solanaServer || DEFAULT_SOLANA_SERVER), shineServer: String(settings?.shineServer || DEFAULT_SHINE_SERVER), arweaveServer: String(settings?.arweaveServer || DEFAULT_ARWEAVE_SERVER), + callPreflightTimeoutMs: Math.max(1000, Math.min(20000, Number(settings?.callPreflightTimeoutMs || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS) || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS)), statuses: { solanaServer: String(settings?.statuses?.solanaServer || 'idle'), shineServer: String(settings?.statuses?.shineServer || 'idle'), @@ -208,6 +210,7 @@ function createInitialState({ withStoredSession = true } = {}) { solanaServer: String(storedEntrySettings?.solanaServer || DEFAULT_SOLANA_SERVER), shineServer: String(LOCAL_WS_OVERRIDE_URL || storedEntrySettings?.shineServer || initialShineServer), arweaveServer: String(storedEntrySettings?.arweaveServer || DEFAULT_ARWEAVE_SERVER), + callPreflightTimeoutMs: Math.max(1000, Math.min(20000, Number(storedEntrySettings?.callPreflightTimeoutMs || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS) || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS)), statuses: { solanaServer: String(storedEntrySettings?.statuses?.solanaServer || 'idle'), shineServer: String(storedEntrySettings?.statuses?.shineServer || 'idle'),