Steven
Steven6 min läsning

En tappad anslutning borde inte krascha hela din app — men vår gjorde det

När vår backend gick offline mitt i ett möte pausade den inte bara transkriberingen — den kraschade hela appen. Orsaken var en enda ohanterad händelse, och fixen var tio rader. Det här är releaseklustret som fick GeekBye att förbli inloggad och förbli ansluten genom de saker som förr tog livet av den.

Utveckling
Tillförlitlighet
Electron
GeekBye-releaser
En tappad anslutning borde inte krascha hela din app — men vår gjorde det

Vissa buggar avbryter en funktion. De värsta drar med sig allt runt omkring den. I en tidig GeekBye-build, om vår backend gick offline medan du var mitt i ett möte, stannade inte transkriberingen bara — hela appen kraschade. En tappad anslutning, och hela fönstret var borta.

Klustret av releaser som fixade detta — v1.6.8 till v1.6.11 — är en mästarlektion i det oglamorösa arbetet med att "förbli inloggad, förbli ansluten". Huvudfixen var tio rader. Här är vad de tio raderna var, och allt annat som skeppades tillsammans med dem.

Tio rader mellan en frånkoppling och en krasch

Kraschen bodde i ljudkoden. När en transkriberingssession rev ner sina WebSockets anropade den removeAllListeners() på dem — rimlig städning. Men här är Node.js-detaljen som förvandlar städning till katastrof: en error-händelse utan lyssnare ignoreras inte. Den kastas om som ett ofångat undantag.

Så föreställ dig händelseförloppet när backenden går offline: socketen har en error-händelse som står i kö för att avfyras. Städningen kör removeAllListeners() och tar bort hanteraren som skulle ha fångat den. Ett ögonblick senare avfyras den köade felhändelsen — ut i tomma intet. Node ser en error-händelse som ingen lyssnar på och gör det enda dess kontrakt tillåter: det kastar den som ett ofångat undantag, vilket kraschar processen. Användarens backend hackade i två sekunder och deras app försvann.

Fixen (v1.6.8) är nästan antiklimaktisk. Direkt efter att lyssnarna tagits bort, koppla tillbaka en medveten tom error-hanterare:

this.micWebSocket.removeAllListeners()
this.micWebSocket.on('error', () => {}) // absorb late error events

Den tomma funktionen är hela poängen. Den ger den väntande felhändelsen någonstans att landa — en sänka — så att Node har en lyssnare och aldrig kastar om något. Samma commit städade också upp halvöppna socketar när ett anslutningsförsök misslyckas halvvägs, så att en misslyckad anslutning inte kan lämna en levande socket hängande med ett fel som väntar på att avfyras. Commit-kommentaren säger det rakt ut: "Utan detta blir väntande fel till ofångade undantag och kraschar appen." Tio rader förvandlade en fullständig krasch till en tyst, återhämtningsbar frånkoppling.

Lärdomen är värd mer än fixen: varje gång du anropar removeAllListeners() på en socket eller ström som du fortfarande kanske river ner, koppla först tillbaka en tom error-sänka. En ohanterad error-händelse är en krasch, inte en loggrad.

Att förbli inloggad: uppdatera i förväg, och uppdatera vid studsen

Halvan "förbli ansluten" hade en tvilling som hette "förbli inloggad". Före det här klustret gick GeekByes autentiseringstoken helt enkelt ut — efter ett tag blev du utan ceremoni dumpad tillbaka till inloggningsskärmen, mitt i ett arbetsflöde, av inget synligt skäl.

v1.6.8 fixade detta från båda hållen:

  • Proaktivt: en timer uppdaterar token innan den går ut, schemalagd till utgång minus en smart buffert (en bråkdel av tokenens livslängd, med golv och tak). Du är uppdaterad innan du ens skulle märka det.
  • Reaktivt: om en förfrågan ändå kommer tillbaka med 401 — klockglidning, en missad timer, en kall laptop — uppdaterar klienten en gång och gör om den ursprungliga förfrågan exakt en gång. En enda vaktflagga sätter tak för det återförsöket så att en ihållande 401 aldrig kan snurra iväg i en oändlig loop.

Den subtila men avgörande delen: båda vägarna ligger bakom ett mutex. Om tio förfrågningar träffar en 401 i samma ögonblick avfyrar de inte tio uppdateringar — de väntar allihop på den enda uppdatering som pågår och gör sedan om försöket. Och när en uppdatering genuint inte kan räddas låter appen sessionen gå ut smidigt — en tydlig tiosekundersavisering "din session gick ut, logga in igen" — istället för en tyst spark till inloggningsskärmen.

Återanslutning: lägg till den på klienten, flytta den sedan till backenden

v1.6.9 gjorde en tappad transkriberingsanslutning återhämtningsbar istället för fatal. När backenden rapporterade att den uppströms taluppkopplingen hade tappats slutade klienten att visa upp ett fatalt fel och återanslöt istället — med medvetet lugn UX: en enda gul "Återansluter…"-avisering (inte en per försök), en grön "Återansluten"-avisering när den kom tillbaka, och det slutgiltiga "starta om"-felet endast efter att varje försök var uttömt.

Och sedan något jag gillar med det här klustret: en release senare, i v1.6.10, raderade vi den klientsidiga återanslutningslogiken — ungefär 113 rader — och flyttade återanslutningen till backenden, som nu skickar tysta "återansluter"-statusmeddelanden som klienten bara speglar. Vi byggde återhämtningen på klienten, bestämde att backenden var rätt ägare, och flyttade den. Det är inte velande; det är den ärliga livscykeln för ett svårt problem. Tillförlitlighetslogik som delas mellan båda ändar av en anslutning är en dålig lukt — välj en ägare. (Backenden blev till slut den definitiva ägaren av detta, vilket är återanslutningsberättelsen som berättas i varför ditt AI-anteckningsverktyg stannar på dålig Wi-Fi.)

Samma release gjorde inspelningstidsgränserna humana: istället för ett rått fel, en varningsavisering som låter inspelningen fortsätta, och ett tydligt "Inspelningsgräns nådd"-meddelande när den faktiskt stannar — den vänliga meningen lyft fram, det interna prefixet borttaget.

Ett återförsök att härska över dem alla

Vid v1.6.11 hade samma återförsök-med-backoff-logik kopierats in på tre olika ställen — autentisering, uppdateringar, sessionsnedrivning. Så den konsoliderades till en enda modul med en gemensam regel om vad som ens är värt att försöka igen: tillfälliga nätverksfel (anslutning återställd, timeout, DNS-hicka, socket som lägger på) görs om med exponentiell backoff; ett verkligt serverfel misslyckas snabbt, eftersom att göra om en genuin 4xx bara slösar användarens tid. En klassificerare, några namngivna förinställningar, tre anropare migrerade till den.

Vad det här klustret lärde oss

  1. En ohanterad error-händelse är en krasch, inte en loggrad. Node kastar om error-händelser som saknar lyssnare. Ta bort lyssnare på en socket du fortfarande kanske river ner, och du måste koppla tillbaka en tom error-sänka — annars tar en tappad anslutning hela appen med sig.
  2. Uppdatera inloggningsuppgifter före utgång och reaktivt vid en 401 — med ett mutex på båda. Proaktivt håller dig inloggad; reaktivt fångar gränsfallen; mutexet betyder att N samtidiga anropare orsakar exakt en uppdatering, och ett återförsökstak betyder att en dålig token inte kan loopa för evigt.
  3. Gör bara om vid tillfälliga fel, och centralisera klassificeraren. "Är detta värt att göra om?" är ett beslut som hör hemma på ett ställe. Att göra om ett verkligt fel är bara ett långsammare misslyckande.
  4. Bestäm vem som äger återanslutningen. Vi lade till den på klienten och flyttade den sedan till backenden. Återhämtningslogik som bor i båda ändar är en bugggenerator; ge den ett enda hem.

Det här är det tredje kapitlet i tillförlitlighetsberättelsen som blir GeekBye v2. För det föregående kapitlet, se vi raderade 5 000 rader ljudkod och transkript började dyka upp två gånger (v1.6.0); för var återanslutningen till slut landade, varför ditt AI-anteckningsverktyg stannar på dålig Wi-Fi; och för hela bågen, anatomin av att skeppa mjukvara till perfektion.