
Разрыв соединения не должен ронять всё приложение — а наше ронял
Когда наш бэкенд отвалился посреди встречи, он не просто приостановил транскрипцию — он уронил всё приложение. Причиной было единственное необработанное событие, а исправление уместилось в десять строк. Это кластер релизов, благодаря которому GeekBye остаётся залогиненным и на связи через то, что раньше его убивало.
Некоторые баги прерывают функцию. Худшие валят всё вокруг неё. В раннем билде GeekBye, если наш бэкенд отваливался, пока вы были посреди встречи, транскрипция не просто останавливалась — падало всё приложение. Один разрыв соединения — и всё окно исчезало.
Кластер релизов, который это починил — с v1.6.8 по v1.6.11 — это мастер-класс неброской работы в духе «оставайся в системе, оставайся на связи». Заглавное исправление уместилось в десять строк. Вот чем были эти десять строк и всё остальное, что вышло вместе с ними.
Десять строк между разрывом и падением
Падение жило в аудиокоде. Когда сессия транскрипции разбирала свои WebSocket, она вызывала на них removeAllListeners() — разумная очистка. Но вот деталь Node.js, которая превращает очистку в катастрофу: событие error без слушателя не игнорируется. Оно перебрасывается как непойманное исключение.
Так что представьте последовательность, когда бэкенд отваливается: у сокета есть событие error, поставленное в очередь на срабатывание. Очистка запускает removeAllListeners(), снимая обработчик, который бы его поймал. Мгновением позже поставленная в очередь ошибка срабатывает — в пустоту. Node видит событие error, которое никто не слушает, и делает единственное, что позволяет его контракт: перебрасывает его как непойманное исключение, что роняет процесс. Бэкенд пользователя икнул на две секунды — и его приложение исчезло.
Исправление (v1.6.8) почти антиклиматично. Сразу после снятия слушателей заново навесьте намеренно пустой обработчик error:
this.micWebSocket.removeAllListeners()
this.micWebSocket.on('error', () => {}) // absorb late error events
Эта пустая функция — весь смысл. Она даёт ожидающей ошибке место, куда приземлиться — приёмник — так что у Node есть слушатель, и оно никогда не перебрасывает заново. Тот же коммит также подчистил полуоткрытые сокеты, когда попытка подключения проваливается на полпути, чтобы запоротое подключение не могло оставить живой сокет болтаться с ожидающей ошибкой. Комментарий к коммиту говорит прямо: «Без этого ожидающие ошибки становятся непойманными исключениями и роняют приложение.» Десять строк превратили полное падение в тихий, восстановимый разрыв.
Урок стоит больше, чем исправление: всякий раз, когда вы вызываете removeAllListeners() на сокете или потоке, который, возможно, ещё разбираете, сначала заново навесьте пустой приёмник error. Необработанное событие error — это падение, а не строка в логе.
Оставаться в системе: обновляй заранее и обновляй на отскоке
У половины «оставайся на связи» была близнец — половина «оставайся в системе». До этого кластера токен аутентификации GeekBye просто истекал — спустя время вас без церемоний выбрасывало обратно на экран логина, посреди работы, без видимой причины.
v1.6.8 починил это с обеих сторон:
- Проактивно: таймер обновляет токен до истечения, запланированный на момент истечения минус умный буфер (доля времени жизни токена, ограниченная снизу и сверху). Вас обновляют раньше, чем вы вообще заметите.
- Реактивно: если запрос всё равно возвращается с
401— сдвиг часов, пропущенный таймер, остывший ноутбук — клиент обновляет токен один раз и повторяет исходный запрос ровно один раз. Единственный флаг-страж ограничивает этот повтор, так что упорный 401 никогда не сможет закрутиться в бесконечный цикл.
Тонкая, но критическая часть: обе ветки стоят за мьютексом. Если десять запросов упираются в 401 в одно и то же мгновение, они не запускают десять обновлений — они все ждут одно обновление в полёте, а затем повторяют. А когда обновление действительно нельзя спасти, приложение завершает сессию плавно — понятное десятисекундное уведомление «ваша сессия истекла, войдите снова» — вместо тихого пинка на экран логина.
Переподключение: добавь его на клиенте, потом перенеси на бэкенд
v1.6.9 сделал разорванное соединение транскрипции восстановимым вместо фатального. Когда бэкенд сообщал, что вышестоящее речевое соединение оборвалось, клиент переставал выдавать фатальную ошибку и вместо этого переподключался — с намеренно спокойным UX: одно янтарное уведомление «Переподключение…» (не по одному на попытку), зелёное уведомление «Переподключено», когда оно возвращалось, и терминальная ошибка «пожалуйста, перезапустите» только после того, как исчерпана каждая попытка.
Затем то, что мне нравится в этом кластере: релизом позже, в v1.6.10, мы удалили эту логику переподключения на стороне клиента — около 113 строк — и перенесли переподключение на бэкенд, который теперь испускает тихие сообщения статуса «переподключение», которые клиент просто отражает. Мы построили восстановление на клиенте, решили, что бэкенд — правильный владелец, и перенесли его. Это не метание; это честный жизненный цикл трудной задачи. Логика надёжности, размазанная по обоим концам соединения, — это дурной запах: выберите владельца. (Бэкенд в итоге стал окончательным владельцем этого, что и есть история переподключения, рассказанная в почему ваш ИИ-заметочник останавливается на плохом Wi-Fi.)
Тот же релиз сделал лимиты времени записи человечными: вместо сырой ошибки — предупреждающее уведомление, которое позволяет записи продолжаться, и понятное сообщение «Достигнут лимит записи», когда она действительно останавливается — дружелюбная фраза вынесена наружу, внутренний префикс убран.
Один повтор, чтобы править всеми
К v1.6.11 та же логика повтора с бэкоффом была скопирована в три разных места — аутентификация, обновления, разбор сессии. Так что её консолидировали в один модуль с одним общим правилом о том, что вообще стоит повторять: переходные сетевые сбои (сброс соединения, таймаут, икота DNS, разрыв сокета) повторяются с экспоненциальным бэкоффом; настоящая ошибка сервера падает быстро, потому что повтор подлинного 4xx только тратит время пользователя. Один классификатор, несколько именованных пресетов, три вызывающих стороны, перенесённых на него.
Чему научил этот кластер
- Необработанное событие
error— это падение, а не строка в логе. Node перебрасывает события error, у которых нет слушателя. Снимите слушателей с сокета, который, возможно, ещё разбираете, и вы обязаны заново навесить пустой приёмник error — иначе один разрыв соединения заберёт с собой всё приложение. - Обновляй учётные данные до истечения и реактивно при 401 — с мьютексом на обоих. Проактивное держит вас в системе; реактивное ловит краевые случаи; мьютекс означает, что N одновременных вызывающих сторон вызывают ровно одно обновление, а лимит повторов означает, что плохой токен не может зациклиться навсегда.
- Повторяй только при переходных ошибках и централизуй классификатор. «Стоит ли это повторять?» — это одно решение, которое принадлежит одному месту. Повтор настоящей ошибки — это просто более медленный провал.
- Реши, кто владеет переподключением. Мы добавили его на клиенте, потом перенесли на бэкенд. Логика восстановления, живущая на обоих концах, — это генератор багов; дайте ей единственный дом.
Это третья глава истории о надёжности, которая становится GeekBye v2. Про предыдущую главу см. мы удалили 5000 строк аудиокода, и транскрипции начали появляться дважды (v1.6.0); про то, где переподключение в итоге осело, почему ваш ИИ-заметочник останавливается на плохом Wi-Fi; а про всю дугу, анатомию доведения софта до совершенства.