Steven
Steven6 мин. чтения

Разрыв соединения не должен ронять всё приложение — а наше ронял

Когда наш бэкенд отвалился посреди встречи, он не просто приостановил транскрипцию — он уронил всё приложение. Причиной было единственное необработанное событие, а исправление уместилось в десять строк. Это кластер релизов, благодаря которому GeekBye остаётся залогиненным и на связи через то, что раньше его убивало.

Инженерия
Надёжность
Electron
Релизы 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 только тратит время пользователя. Один классификатор, несколько именованных пресетов, три вызывающих стороны, перенесённых на него.

Чему научил этот кластер

  1. Необработанное событие error — это падение, а не строка в логе. Node перебрасывает события error, у которых нет слушателя. Снимите слушателей с сокета, который, возможно, ещё разбираете, и вы обязаны заново навесить пустой приёмник error — иначе один разрыв соединения заберёт с собой всё приложение.
  2. Обновляй учётные данные до истечения и реактивно при 401 — с мьютексом на обоих. Проактивное держит вас в системе; реактивное ловит краевые случаи; мьютекс означает, что N одновременных вызывающих сторон вызывают ровно одно обновление, а лимит повторов означает, что плохой токен не может зациклиться навсегда.
  3. Повторяй только при переходных ошибках и централизуй классификатор. «Стоит ли это повторять?» — это одно решение, которое принадлежит одному месту. Повтор настоящей ошибки — это просто более медленный провал.
  4. Реши, кто владеет переподключением. Мы добавили его на клиенте, потом перенесли на бэкенд. Логика восстановления, живущая на обоих концах, — это генератор багов; дайте ей единственный дом.

Это третья глава истории о надёжности, которая становится GeekBye v2. Про предыдущую главу см. мы удалили 5000 строк аудиокода, и транскрипции начали появляться дважды (v1.6.0); про то, где переподключение в итоге осело, почему ваш ИИ-заметочник останавливается на плохом Wi-Fi; а про всю дугу, анатомию доведения софта до совершенства.

Похожие статьи

Проверка безопасности, из-за которой приложение стало невозможно закрыть
Steven
Steven5 мин. чтения

Проверка безопасности, из-за которой приложение стало невозможно закрыть

Автообновление оказалось самой трудной функцией, которую мы когда-либо выпускали, — шесть релизов за четыре дня, чтобы оно перестало ломать приложение. Худший баг мы внесли сами, пытаясь быть осторожными: 500-миллисекундная «защитная» проверка, превращавшая неудачное обновление в процесс, который буквально нельзя было закрыть.

Инженерия
Electron
Надёжность
День, когда наше приложение устроило DDoS самому себе
Steven
Steven5 мин. чтения

День, когда наше приложение устроило DDoS самому себе

Копившиеся отложенные загрузки, выпущенные все разом при запуске, превратили каждый клиент GeekBye в маленькую атаку denial-of-service на наши собственные серверы. Исправление — и лестница liveness соединения, которую оно заставило нас построить — одна из самых полезных вещей, которым научил нас v2.

Надёжность
Сети
Инженерия
Чего на самом деле стоит версия 2: 206 коммитов честных состояний
Steven
Steven6 мин. чтения

Чего на самом деле стоит версия 2: 206 коммитов честных состояний

GeekBye v2 не был релизом новых функций. Это были 206 коммитов, нацеленных на одну идею: приложение никогда не должно врать о собственном состоянии. Вот чего это стоит — включая однострочную ошибку в lockfile, которая едва не помешала нам выпустить хоть что-то из этого.

Надёжность
Инженерия
Релиз