Steven
Steven6 min læsning

En mistet forbindelse burde ikke crashe hele din app — men vores gjorde

Da vores backend gik offline midt i et møde, satte den ikke bare transskriptionen på pause — den crashede hele appen. Årsagen var en enkelt uhåndteret hændelse, og rettelsen var ti linjer. Dette er den release-klynge, der fik GeekBye til at forblive logget ind og forblive forbundet gennem de ting, der før tog livet af den.

Udvikling
Pålidelighed
Electron
GeekBye-releases
En mistet forbindelse burde ikke crashe hele din app — men vores gjorde

Nogle bugs afbryder en funktion. De værste tager alt omkring den med sig. I en tidlig GeekBye-build, hvis vores backend gik offline, mens du var midt i et møde, stoppede transskriptionen ikke bare — hele appen crashede. En mistet forbindelse, og hele vinduet var væk.

Klyngen af releases, der rettede dette — v1.6.8 til v1.6.11 — er en mesterlektion i det uglamourøse arbejde med at "forblive logget ind, forblive forbundet". Hovedrettelsen var ti linjer. Her er, hvad de ti linjer var, og alt andet, der blev skibet sammen med dem.

Ti linjer mellem en afbrydelse og et crash

Crashet boede i lydkoden. Da en transskriptionssession rev sine WebSockets ned, kaldte den removeAllListeners() på dem — fornuftig oprydning. Men her er den Node.js-detalje, der forvandler oprydning til katastrofe: en error-hændelse uden lytter bliver ikke ignoreret. Den bliver kastet igen som en ufanget undtagelse.

Så forestil dig forløbet, når backenden går offline: socketen har en error-hændelse stående i kø til at blive udløst. Oprydningen kører removeAllListeners() og fjerner den handler, der ville have fanget den. Et øjeblik senere udløses den køede fejl — ud i det tomme rum. Node ser en error-hændelse, som ingen lytter på, og gør det eneste, dens kontrakt tillader: den kaster den som en ufanget undtagelse, hvilket crasher processen. Brugerens backend hikkede i to sekunder, og deres app forsvandt.

Rettelsen (v1.6.8) er næsten antiklimaktisk. Lige efter at lytterne er fjernet, tilkobl igen en bevidst tom error-handler:

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

Den tomme funktion er hele pointen. Den giver den ventende fejl et sted at lande — en sink — så Node har en lytter og aldrig kaster noget igen. Samme commit ryddede også op i halvåbne sockets, når et forbindelsesforsøg mislykkes halvvejs, så en forkludret forbindelse ikke kan efterlade en levende socket hængende med en fejl, der venter på at blive udløst. Commit-kommentaren siger det lige ud: "Uden dette bliver ventende fejl til ufangede undtagelser og crasher appen." Ti linjer forvandlede et fuldt crash til en tavs, genoprettelig afbrydelse.

Lektionen er mere værd end rettelsen: hver gang du kalder removeAllListeners() på en socket eller stream, som du måske stadig river ned, tilkobl først igen en tom error-sink. En uhåndteret error-hændelse er et crash, ikke en logline.

At forblive logget ind: forny på forkant, og forny ved rebounden

Halvdelen "forbliv forbundet" havde en tvilling ved navn "forbliv logget ind". Før denne klynge udløb GeekByes autentificerings-token simpelthen — efter et stykke tid blev du uden videre smidt tilbage til login-skærmen, midt i et arbejdsflow, uden nogen synlig grund.

v1.6.8 rettede dette fra begge retninger:

  • Proaktivt: en timer fornyer token inden det udløber, planlagt til udløb minus en smart buffer (en brøkdel af tokenets levetid, med gulv og loft). Du er fornyet, inden du overhovedet ville bemærke det.
  • Reaktivt: hvis en forespørgsel alligevel kommer tilbage med 401 — urforskydning, en overset timer, en kold laptop — fornyer klienten én gang og gentager den oprindelige forespørgsel præcis én gang. Et enkelt vagtflag sætter loft over det genforsøg, så en vedvarende 401 aldrig kan spinne ud i en uendelig løkke.

Den subtile, men afgørende del: begge veje ligger bag en mutex. Hvis ti forespørgsler rammer en 401 i samme øjeblik, affyrer de ikke ti fornyelser — de venter allesammen på den ene fornyelse, der er i gang, og gentager så. Og når en fornyelse virkelig ikke kan reddes, lader appen sessionen udløbe glidende — en tydelig ti-sekunders notifikation "din session udløb, log venligst ind igen" — i stedet for et tavst spark til login-skærmen.

Genforbindelse: tilføj den på klienten, flyt den så til backenden

v1.6.9 gjorde en mistet transskriptionsforbindelse genoprettelig i stedet for fatal. Da backenden rapporterede, at den opstrøms taleforbindelse var mistet, holdt klienten op med at vise en fatal fejl og genforbandt i stedet — med bevidst rolig UX: en enkelt gul "Genforbinder…"-notifikation (ikke én per forsøg), en grøn "Genforbundet"-notifikation, da den kom tilbage, og den endelige "genstart venligst"-fejl kun efter at hvert forsøg var udtømt.

Og så noget, jeg godt kan lide ved denne klynge: en release senere, i v1.6.10, slettede vi den klientsidede genforbindelseslogik — omkring 113 linjer — og flyttede genforbindelsen til backenden, som nu udsender tavse "genforbinder"-statusbeskeder, som klienten bare afspejler. Vi byggede genopretningen på klienten, besluttede at backenden var den rette ejer, og flyttede den. Det er ikke vaklen; det er den ærlige livscyklus for et svært problem. Pålidelighedslogik delt mellem begge ender af en forbindelse er en dårlig lugt — vælg en ejer. (Backenden blev til sidst den definitive ejer af dette, hvilket er genforbindelseshistorien, der fortælles i hvorfor dit AI-notatværktøj stopper på dårligt Wi-Fi.)

Samme release gjorde optagetids­grænserne humane: i stedet for en rå fejl, en advarselsnotifikation, der lader optagelsen fortsætte, og en tydelig "Optagegrænse nået"-besked, når den faktisk stopper — den venlige sætning fremhævet, det interne præfiks fjernet.

Ét genforsøg til at herske over dem alle

Ved v1.6.11 var den samme genforsøg-med-backoff-logik blevet kopieret ind tre forskellige steder — autentificering, opdateringer, session-nedrivning. Så den blev konsolideret til et enkelt modul med én fælles regel om, hvad der overhovedet er værd at prøve igen: forbigående netværksfejl (forbindelse nulstillet, timeout, DNS-hikke, socket der lægger på) prøves igen med eksponentiel backoff; en reel serverfejl fejler hurtigt, fordi det bare spilder brugerens tid at prøve en ægte 4xx igen. Én klassificerer, et par navngivne forudindstillinger, tre kaldere migreret over på den.

Hvad denne klynge lærte

  1. En uhåndteret error-hændelse er et crash, ikke en logline. Node kaster error-hændelser uden lytter igen. Fjern lyttere på en socket, du måske stadig river ned, og du skal tilkoble en tom error-sink igen — ellers tager én mistet forbindelse hele appen med sig.
  2. Forny loginoplysninger før udløb og reaktivt ved en 401 — med en mutex på begge. Proaktivt holder dig logget ind; reaktivt fanger grænsetilfældene; mutexet betyder, at N samtidige kaldere forårsager præcis én fornyelse, og et genforsøgsloft betyder, at et dårligt token ikke kan løkke i det uendelige.
  3. Prøv kun igen ved forbigående fejl, og centralisér klassificereren. "Er dette værd at prøve igen?" er én beslutning, der hører hjemme ét sted. At prøve en reel fejl igen er bare en langsommere fejl.
  4. Beslut, hvem der ejer genforbindelsen. Vi tilføjede den på klienten og flyttede den så til backenden. Genopretningslogik, der bor i begge ender, er en bug-generator; giv den ét hjem.

Dette er det tredje kapitel i pålidelighedshistorien, der bliver til GeekBye v2. For det forrige kapitel, se vi slettede 5.000 linjer lydkode, og transskriptioner begyndte at dukke op to gange (v1.6.0); for hvor genforbindelsen til sidst landede, hvorfor dit AI-notatværktøj stopper på dårligt Wi-Fi; og for hele buen, anatomien i at skibe software til perfektion.