
Розрив з'єднання не повинен ронити весь застосунок — а наш ронив
Коли наш бекенд відвалився посеред зустрічі, він не просто призупинив транскрипцію — він поклав увесь застосунок. Причиною була одна необроблена подія, а виправлення вмістилося в десять рядків. Це кластер релізів, завдяки якому 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; а про всю дугу, анатомію доведення софту до досконалості.