LiveKit казался простым, пока не начал протухать токен
- среда, 7 октября 2026 г. в 00:00:05
Synkflow — наш голосовой мессенджер (бэкенд на Go, фронтенд на React/TypeScript, а для медиапотоков WebRTC под капотом крутится LiveKit).
С самого начала разработки одной из самых неприятных проблем стал именно LiveKit. На первый взгляд он казался простым: в документации всё вроде понятно написано — поднял комнату, выдал токен, пошёл звук. А по факту всё упёрлось в протухание токена.
Из‑за этого нас выкидывало из звонка, потом само переподключало, часто прямо перед тем, как токен должен был затухнуть. Бывало, что после этого тебя просто не слышали. Демонстрация и вебка при таком подъёме тоже пропадали: в канал ты уже вернулся, а экран и камеру надо включать заново. И всё в таком роде. С апреля где‑то до сентября мы это и разгребали.
Попутно ещё был сокет. Человек уже зашёл в голосовой, а в сайдбаре его нет, или был и пропал. Слышно при этом часто было. Это отдельная история, но снаружи выглядело так же: «ты вылетел».
Кто сейчас в канале, хранит наш сервер и рассылает по WebSocket: зашёл, вышел, замьютился. Звук, вебка и демонстрация едут через LiveKit. Там ты не пользователь Synkflow, а просто user_id.
браузер ├─ WebSocket ──► наш сервер ──► сайдбар: кто в канале └─ LiveKit ──► SFU ──► микрофон, вебка, демонстрация, одинаковый user_id с двух сторон, общего мозга нет
Пока оба соединения живы, можно делать вид, что это один звонок. Потом отваливается что‑то одно, и клиент начинает сам себе верить. Сокет сказал «вышел» — клиент разбирает комнату LiveKit. LiveKit сказал «участника сняли» — клиент шлёт leave в сокет. Документация LiveKit про этот кусок почти ничего не говорит, потому что для неё наш сокет — это уже не её проблема.
Дальше почти каждый баг был про то, что одна голова гидры моргнула, а вторая ещё говорила.
Сначала мы сделали как в голове и рисуется. Закрылся последний WebSocket — значит, человека нет. Сервер сразу выкидывал его из голоса. В логах красиво. В сайдбаре пусто.
На деле сокет моргает от всего подряд. VPN на секунду просел, вкладка уснула, прокси отвалился по таймауту. LiveKit про это часто даже не знает, у него своё соединение. Микрофон живой, а в канале тебя уже не рисуют. Люди пишут «ты вылетел», ты смотришь в клиент и вроде никуда не делся.
Выход мы отложили на 8 секунд. Вернулся и заново сказал voice_join — таймер снимается, сидишь как сидел. Сам нажал выйти или админ выгнал — это другой путь, там без паузы. Восемь секунд никто не считал. На пяти людей всё ещё выкидывало из списка. На пятнадцати закрытая вкладка слишком долго висела мёртвым участником. Восемь — то, на чём нас перестали пинать.
const defaultVoiceDisconnectGrace = 8 time.Second func (h Hub) scheduleDeferredVoiceLeave(userID uuid.UUID, username string) { h.pendingVoiceMu.Lock() defer h.pendingVoiceMu.Unlock() h.cancelDeferredVoiceLeaveLocked(userID) h.voiceLeaveGens[userID]++ gen := h.voiceLeaveGens[userID] p := &pendingVoiceLeave{username: username, generation: gen} p.timer = time.AfterFunc(h.voiceGrace(), func() { h.fireDeferredVoiceLeave(userID, username, gen) }) h.pendingVoiceLeaves[userID] = p }
Потом таймер укусил сам себя. Stop() не отменяет колбэк, если планировщик его уже снял с полки. Сокет упал, до выхода осталось чуть‑чуть, человек успел вернуться, а старый колбэк всё равно доезжал и убирал его из канала заново. Выглядело как «я переподключился, и в сайдбаре меня сразу нет». Долго смотрели на клиент. Клиент был ни при чём.
Пришлось нумеровать попытки. Колбэк перед выходом проверяет, что это всё ещё он. Чужой номер — просто уходит.
func (h *Hub) fireDeferredVoiceLeave(userID uuid.UUID, username string, gen uint64) { h.pendingVoiceMu.Lock() p, ok := h.pendingVoiceLeaves[userID] if !ok || p.generation != gen { h.pendingVoiceMu.Unlock() return } delete(h.pendingVoiceLeaves, userID) h.pendingVoiceMu.Unlock() _ = h.getVoiceHandler().HandleVoiceLeave(userID, username) }
На выключении сервера эти 8 секунд ждать нельзя: Hub.Wait() стоит прямо перед закрытием Postgres. Если ждать честно, деплой просто висит. Там отложенные выходы сбрасываются сразу.
Серверная пауза починила сайдбар для остальных. Тебе самому от неё не легче. Событие «ты вышел» прилетает по куче поводов: админ, смена канала, второй клиент, тот самый опоздавший таймер. Клиент видел своё имя и разбирал звонок. Иногда микрофон ещё живой, а интерфейс уже в лобби. Иногда он успевал подняться обратно, но камеру и демонстрацию к этому моменту уже отпустил. Отсюда как раз «меня переподключило, демки нет, вебки нет».
Поставили короткую паузу и на клиенте, около секунды. Если LiveKit ещё connected или хотя бы reconnecting, мы не уходим. Наоборот стучимся обратно: я на месте, вот канал, обнови список. Если за эту секунду умер и LiveKit, тогда уже выходим.
if (connectionState === 'connected' || connectionState === 'reconnecting') { const stuckChannelId = channelId; scheduleSelfVoiceLeaveCheck(() => { const s = useVoiceStore.getState(); if (s.channelId !== stuckChannelId) return; if (s.connectionState === 'connected' || s.connectionState === 'reconnecting') { wsClient.send('channel_join', { channel_id: stuckChannelId }); wsClient.send('voice_join', { channel_id: stuckChannelId }); void s.refreshParticipants(); return; } void s.leaveChannel({ skipServerLeave: true }); }); }
skipServerLeave появился после совсем тупого случая. Сидишь в браузере, потом открываешь десктоп на том же аккаунте. Вкладка понимает, что сессию забрали, и на прощание шлёт POST /voice/leave. Сервер честный: пользователь вышел, убираем его из комнаты. Только комната уже принадлежит десктопу. Старая вкладка сносит живой звонок. После этого локальная уборка на сервер больше не пишется. channelId обнуляем сразу, иначе закрытие вкладки успевает послать ещё один leave через pagehide, и история повторяется уже сама.
Чужие плитки с первого voice_leave тоже не сносим. Пришло «Вася вышел», а Вася всё ещё сидит в комнате LiveKit. Значит, сокет отстал, звук нет. Плитка пусть повисит. Это как раз сайдбар, не демка.
Дальше выяснилось, что LiveKit в принципе не готов к двум тебе сразу. Identity один. Две сессии с одним user_id в одной комнате он не держит. Для «один человек — один голос» это даже нормально. Для «сидел во вкладке и открыл десктоп» — нет.
Когда заходишь, сервер перед выдачей токена снимает предыдущего тебя через RemoveParticipant. Старый клиент получает PARTICIPANT_REMOVED или DUPLICATE_IDENTITY. В первой версии мы вместе с этим ещё и слали voice_leave на тот же канал. Новый клиент ещё даже не доехал до LiveKit, ловил «ты вышел» на своё имя и начинал разбирать звонок, в который только что зашёл. Сам себя встречал и сам себя выгонял. В логах оба события правильные, от этого только обиднее.
Leave на тот же канал мы больше не шлём. Старую медиасессию кикаем молча. Событие выхода уходит только если ты правда перешёл в другой канал, там соседям надо знать, что тебя нет.
На клиенте Disconnected одно, а смыслов у него несколько. Нас выгнали из другого клиента. Мы сами только что пересоздали комнату. Это запоздавшее событие от старой комнаты. Или просто упала связь. Какое‑то время это был ком условий в хуке на пару экранов. Ломалось каждый раз, когда появлялся ещё один повод переподключиться. В итоге вынесли в отдельную функцию, её хотя бы можно потыкать тестом без LiveKit.
Событие от комнаты, в которой нас уже нет, выбрасываем. Свой собственный повторный вход считаем реконнектом и демонстрацию не тушим, если захват ещё живой. Чужой клиент — тост «сессия продолжена на другом устройстве» и локальный выход, без POST. Иначе старая вкладка снесёт звонок, который только что поднял десктоп. Обычный обрыв LiveKit часто вытягивает сам. Если после Reconnected трек экрана ещё live, публикуем тот же трек заново. Вебку так же стараемся не бросать: если её отпустили при разборе комнаты, для собеседника это просто чёрный квадрат.
Почему «тот же трек», а не «просто включи демку ещё раз», — в следующем куске. Это как раз то место, где браузер говорит нет.
Вот этот баг я люблю больше остальных, потому что он был стабильный. Не «иногда на VPN». Сел в канал, включил экран, включил камеру, оставил висеть. Где‑то через полтора часа выкидывает. Почти сразу сажает обратно. Ты снова в канале, микрофон часто успевает подняться сам. Демонстрации нет. Вебки нет. Иди включай заново и объясняй, почему только что показывал экран, а теперь аватарку. Иногда после этого тебя ещё и не слышали, пока микрофон сам не переподнимется.
Токен LiveKit к этому моменту как раз подходил к концу. Мы про это знали и заранее ходили за новым, чтобы не доживать до протухания. Ходили в POST /voice/join, потому что join и так выдаёт токен, зачем плодить ручки. А join, как выше, каждый раз снимает предыдущего участника с тем же user_id. Предыдущий участник — это ты. Клиент видел PARTICIPANT_REMOVED, думал, что зашли с другого клиента, и начинал пересобирать комнату. В канал он потом возвращался. Захват экрана и камеры к этому моменту уже были отпущены. Мы кикали сами себя по таймеру, чтобы обновить бумажку.
Тихо поднять демку обратно из колбэка переподключения нельзя. navigator.mediaDevices.getDisplayMedia() браузер пускает только с клика: transient user activation, прямой жест пользователя. Иначе любая вкладка могла бы начать писать экран, пока человек отошёл за чаем. Вызов из таймера, из сокета или из RoomEvent.Reconnected без клика ловит NotAllowedError, системное окно «чем хотите поделиться» даже не откроется. Поэтому потеря комнаты — это не «сейчас сами перезапустим стрим». Это конец стрима, пока человек сам не нажмёт кнопку.
Микрофон и камера после уже выданного разрешения часто поднимаются из кода. Демонстрация — нет. Каждый новый захват экрана снова требует клик и снова показывает пикер. Единственный обход, который у нас завёлся: не останавливать MediaStreamTrack. Если после реконнекта он всё ещё live, публикуем его же, без getDisplayMedia. Как только disconnect сделал track.stop(), этот путь закрыт. Отсюда и вся возня «не пересоздавай комнату ради нового JWT».
Отдельная ручка теперь только подписывает новый JWT. Комнату не трогает, в сокет ничего не орёт, RemoveParticipant не вызывает.
func (s *VoiceService) RefreshToken(...) (*RefreshTokenResult, error) { current, inRoom := s.rooms.GetUserRoom(userID) if !inRoom || current != channelID { return nil, ErrNotInVoiceChannel } // членство и право зайти в голос проверяются так же, как в Join token, err := s.livekitToken(userID, username, channelID) if err != nil { return nil, ErrVoiceTokenFailed } return &RefreshTokenResult{Token: token, LiveKitURL: s.livekitPublicURL}, nil }
Для канала это POST /api/voice/channels/{id}/token, для звонка в личке своя, такая же. Обычный заход старую сессию по‑прежнему убивает. Там это и нужно, если ты правда открыл второй клиент.
На клиенте токен записывается в уже живую комнату. Переподключаться нельзя: disconnect останавливает треки, и человек, который полтора часа что‑то показывал, снова должен кликнуть «поделиться экраном». Мы так один раз уже сделали. Больше не хочется.
function applyLiveKitTokenInPlace(room: Room, token: string): void { (room.engine as unknown as { token: string }).token = token; }
Да, лезем в непубличное поле. Нормального метода в духе «вот тебе новый JWT, комнату не трогай» в используемой версии SDK (livekit-client v2.x) на тот момент не нашлось. При каждом обновлении пакета этот кусок приходится проверять руками. Иначе длинный стрим снова оборвётся ровно на рефреше, клиент бодро переподключится, а демки уже не будет: браузер без клика её не вернёт.
Если мы сами в этот момент заново входим, потому что сокет умер по‑настоящему, подмену пропускаем. Join всё равно убьёт комнату, нужен обычный connect. Чтобы клиент не перепутал это с чужим десктопом, вокруг своего входа стоит окно секунд на 15. PARTICIPANT_REMOVED внутри окна значит «это мы, поднимайся заново и попробуй вернуть те же треки, если они ещё live». Снаружи — «место заняли, уходи тихо».
export const OWN_VOICE_JOIN_KICK_WINDOW_MS = 15_000; export function markOwnVoiceJoinKickExpected() { expectedUntil = Date.now() + OWN_VOICE_JOIN_KICK_WINDOW_MS; }
Когда ставить таймер, смотрим на exp в токене и отнимаем 15 минут, чтобы на переподключение ещё остался живой токен. Если exp не разобрался, ждём 90 минут: столько токен жил раньше целиком. Не вышло выписать новый — повтор через минуту, не через полтора часа. Иначе ошибка сети превращается в гарантированный кик как раз перед протуханием.
export function liveKitTokenRefreshDelayMs(token: string, now = Date.now()): number { const exp = getJwtExp(token); if (exp == null) return 90 * 60 * 1000; const delay = exp * 1000 - now - 15 * 60 * 1000; return Math.max(60 * 1000, delay); }
И последнее, уже из серии «ну конечно». setTimeout на полтора часа после сна ноутбука не стреляет, пока процесс спит. Проснулся — токен уже мёртвый, таймер ещё думает. Поэтому ту же проверку дёргаем, когда вкладка снова видна, когда окно десктопа получило фокус, и когда Electron говорит onAppResumed. Фокуса одного мало: крышку открыл, окно и так было активным, события фокуса нет, а токен умер. Сокет у нас на пробуждение уже переподключался. Голос просто сел на тот же крючок.
livekitUrl из ответа рефреша в стор тоже не пишем. Браузер ходит туда, куда ему сказали при сборке. В ответе бэкенда спокойно может лежать адрес, по которому LiveKit виден только из Docker, и следующий connect уезжает в сеть, где нет ни одного браузера. Час отладки, причина на одну строку.
Закрытие вкладки живёт рядом, хотя задача другая. Восемь секунд — это чтобы человека не стирало из сайдбара на моргнувшем сокете. Для «закрыл крышку и ушёл» это много. На pagehide клиент, пока ещё может, шлёт короткий leave. Иначе ты висишь в канале призраком.
В итоге две головы так и остались двумя. Сокет отвечает за то, рисуют ли тебя в списке. LiveKit — за то, есть ли звук, вебка и экран. Пока он знает тебя только как user_id, второй клиент всегда убивает первого. А любой код, который случайно сходил в обычный join, делает то же самое от твоего имени и по дороге теряет демку: браузер без клика её не отдаст. Паузы и номер таймера только не дают этому случаться от плохого вайфая и от нашего же обновления токена.
Попробовать Synkflow можно на synkflow.ru
Обрыв сокета — это не выход из голоса. Пауза на сервере, у нас 8 секунд, и отмена по новому voice_join
time.Timer.Stop() не убивает колбэк, который уже в пути. Нумеруйте попытку и сверяйте номер перед leave
На shutdown эту паузу не ждите. Сбросьте отложенные выходы сразу, иначе деплой висит перед закрытием базы
Свой voice_leave при живом LiveKit не разбирайте сразу. Секунда, потом смотрите, жива ли комната
После захвата сессии другим клиентом не шлите POST /voice/leave. Соберёте уже новую комнату
На том же канале не рассылайте voice_leave вместе с RemoveParticipant. Новый клиент примет его на свой счёт
Новый JWT не берите через join, если join кикает участника с тем же user_id. Отдельная ручка, без RemoveParticipant и без событий в сокет
Не пересоздавайте Room ради токена. getDisplayMedia() без клика не вызовешь, пикер сам не всплывёт
Если трек демонстрации ещё live, публикуйте его же. track.stop() этот путь закрывает
setTimeout на полтора часа врёт после сна. Проверяйте токен ещё на пробуждении вкладки и приложения
URL LiveKit из ответа рефреша в браузерный стор не кладите. Там легко оказывается внутренний адрес Docker
Поле room.engine.token непубличное. После каждого бампа livekit-client проверяйте, что подмена ещё жива
Если будет интересно, могу отдельно написать про WebSocket и реалтайм в проекте. Там своих граблей тоже хватает.