Звонки: preflight сессии перед вызовом и retry; таймаут вынесен в настройки

This commit is contained in:
AidarKC 2026-05-05 18:11:55 +03:00
parent ef0cd2cb7d
commit 6774c26ea1
5 changed files with 116 additions and 3 deletions

View File

@ -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*`.

View File

@ -1,2 +1,2 @@
client.version=1.2.39
server.version=1.2.33
client.version=1.2.40
server.version=1.2.34

View File

@ -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';

View File

@ -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();

View File

@ -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'),