Steven
Steven6 min lesing

En mistet tilkobling burde ikke krasje hele appen din — men vår gjorde det

Da backend-en vår gikk offline midt i et møte, satte den ikke bare transkripsjonen på pause — den krasjet hele appen. Årsaken var én enkelt uhåndtert hendelse, og fiksen var ti linjer. Dette er release-klyngen som fikk GeekBye til å forbli innlogget og forbli tilkoblet gjennom tingene som før tok livet av den.

Utvikling
Pålitelighet
Electron
GeekBye-releaser
En mistet tilkobling burde ikke krasje hele appen din — men vår gjorde det

Noen bugger avbryter en funksjon. De verste drar med seg alt rundt den. I en tidlig GeekBye-build, hvis backend-en vår gikk offline mens du var midt i et møte, stanset ikke transkripsjonen bare — hele appen krasjet. Én mistet tilkobling, og hele vinduet var borte.

Klyngen av releaser som fikset dette — v1.6.8 til v1.6.11 — er en mesterlekse i det uglamorøse arbeidet med å "forbli innlogget, forbli tilkoblet". Hovedfiksen var ti linjer. Her er hva de ti linjene var, og alt annet som ble skipet sammen med dem.

Ti linjer mellom en frakobling og et krasj

Krasjet bodde i lydkoden. Da en transkripsjonsøkt rev ned sine WebSockets, kalte den removeAllListeners() på dem — fornuftig opprydding. Men her er Node.js-detaljen som gjør opprydding til katastrofe: en error-hendelse uten lytter blir ikke ignorert. Den blir kastet på nytt som et ufanget unntak.

Så se for deg forløpet når backend-en går offline: socketen har en error-hendelse som står i kø for å utløses. Oppryddingen kjører removeAllListeners() og fjerner handleren som ville ha fanget den. Et øyeblikk senere utløses den køede feilen — ut i det tomme. Node ser en error-hendelse som ingen lytter på, og gjør det eneste kontrakten dens tillater: den kaster den som et ufanget unntak, som krasjer prosessen. Brukerens backend hikstet i to sekunder, og appen deres forsvant.

Fiksen (v1.6.8) er nesten antiklimaktisk. Rett etter at lytterne er fjernet, koble på nytt til en bevisst tom error-handler:

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

Den tomme funksjonen er hele poenget. Den gir den ventende feilen et sted å lande — en sink — så Node har en lytter og aldri kaster noe på nytt. Samme commit ryddet også opp i halvåpne socketer når et tilkoblingsforsøk feiler halvveis, slik at en mislykket tilkobling ikke kan etterlate en levende socket hengende med en feil som venter på å utløses. Commit-kommentaren sier det rett ut: "Uten dette blir ventende feil til ufangede unntak og krasjer appen." Ti linjer gjorde et fullt krasj om til en stille, gjenopprettelig frakobling.

Lærdommen er verdt mer enn fiksen: hver gang du kaller removeAllListeners() på en socket eller strøm du kanskje fortsatt river ned, koble først på nytt til en tom error-sink. En uhåndtert error-hendelse er et krasj, ikke en loggrad.

Å forbli innlogget: forny på forhånd, og forny ved rekylen

Halvparten "forbli tilkoblet" hadde en tvilling ved navn "forbli innlogget". Før denne klyngen utløp GeekByes autentiseringstoken rett og slett — etter en stund ble du uten videre kastet tilbake til innloggingsskjermen, midt i en arbeidsflyt, uten noen synlig grunn.

v1.6.8 fikset dette fra begge retninger:

  • Proaktivt: en timer fornyer tokenet før det utløper, planlagt til utløp minus en smart buffer (en brøkdel av tokenets levetid, med gulv og tak). Du er fornyet før du i det hele tatt ville merke det.
  • Reaktivt: hvis en forespørsel likevel kommer tilbake med 401 — klokkeforskyvning, en bommet timer, en kald laptop — fornyer klienten én gang og gjentar den opprinnelige forespørselen nøyaktig én gang. Ett vaktflagg setter tak på det nye forsøket, slik at en vedvarende 401 aldri kan spinne ut i en uendelig løkke.

Den subtile, men avgjørende delen: begge veier ligger bak en mutex. Hvis ti forespørsler treffer en 401 i samme øyeblikk, utløser de ikke ti fornyelser — de venter alle på den ene fornyelsen som er i gang, og prøver så på nytt. Og når en fornyelse virkelig ikke kan reddes, lar appen økten utløpe sømløst — et tydelig ti-sekunders varsel "økten din utløp, logg inn igjen" — i stedet for et stille spark til innloggingsskjermen.

Gjenoppkobling: legg den til på klienten, flytt den så til backend-en

v1.6.9 gjorde en mistet transkripsjonstilkobling gjenopprettelig i stedet for fatal. Da backend-en rapporterte at den oppstrøms taletilkoblingen var mistet, sluttet klienten å vise en fatal feil og koblet i stedet til på nytt — med bevisst rolig UX: ett gult "Kobler til på nytt…"-varsel (ikke ett per forsøk), et grønt "Tilkoblet på nytt"-varsel da den kom tilbake, og den endelige "start på nytt"-feilen bare etter at hvert forsøk var brukt opp.

Og så noe jeg liker med denne klyngen: én release senere, i v1.6.10, slettet vi den klientsidige gjenoppkoblingslogikken — omtrent 113 linjer — og flyttet gjenoppkoblingen til backend-en, som nå sender stille "kobler til på nytt"-statusmeldinger som klienten bare speiler. Vi bygde gjenopprettingen på klienten, bestemte at backend-en var den rette eieren, og flyttet den. Det er ikke vingling; det er den ærlige livssyklusen til et vanskelig problem. Pålitelighetslogikk delt mellom begge ender av en tilkobling er en dårlig lukt — velg en eier. (Backend-en ble til slutt den definitive eieren av dette, som er gjenoppkoblingshistorien som fortelles i hvorfor AI-notatverktøyet ditt stopper på dårlig Wi-Fi.)

Samme release gjorde grensene for opptakstid humane: i stedet for en rå feil, et advarselsvarsel som lar opptaket fortsette, og en tydelig "Opptaksgrense nådd"-melding når det faktisk stopper — den vennlige setningen løftet frem, det interne prefikset fjernet.

Ett nytt forsøk til å herske over dem alle

Ved v1.6.11 hadde den samme nytt-forsøk-med-backoff-logikken blitt kopiert inn på tre forskjellige steder — autentisering, oppdateringer, nedriving av økt. Så den ble konsolidert til én enkelt modul med én felles regel om hva som i det hele tatt er verdt å prøve på nytt: forbigående nettverksfeil (tilkobling nullstilt, timeout, DNS-hikke, socket som legger på) prøves på nytt med eksponentiell backoff; en reell serverfeil feiler raskt, fordi det bare kaster bort brukerens tid å prøve en ekte 4xx på nytt. Én klassifiserer, noen navngitte forhåndsinnstillinger, tre kallere migrert over på den.

Hva denne klyngen lærte

  1. En uhåndtert error-hendelse er et krasj, ikke en loggrad. Node kaster error-hendelser uten lytter på nytt. Fjern lyttere på en socket du kanskje fortsatt river ned, og du må koble på nytt til en tom error-sink — ellers tar én mistet tilkobling hele appen med seg.
  2. Forny påloggingsinformasjon før utløp og reaktivt ved en 401 — med en mutex på begge. Proaktivt holder deg innlogget; reaktivt fanger kanttilfellene; mutexen betyr at N samtidige kallere forårsaker nøyaktig én fornyelse, og et tak på nye forsøk betyr at et dårlig token ikke kan løkke i det uendelige.
  3. Prøv på nytt bare ved forbigående feil, og sentraliser klassifisereren. "Er dette verdt å prøve på nytt?" er én beslutning som hører hjemme ett sted. Å prøve en reell feil på nytt er bare en tregere feil.
  4. Bestem hvem som eier gjenoppkoblingen. Vi la den til på klienten og flyttet den så til backend-en. Gjenopprettingslogikk som bor i begge ender er en bug-generator; gi den ett hjem.

Dette er det tredje kapittelet i pålitelighetshistorien som blir GeekBye v2. For det forrige kapittelet, se vi slettet 5000 linjer lydkode, og transkripsjoner begynte å dukke opp to ganger (v1.6.0); for hvor gjenoppkoblingen til slutt landet, hvorfor AI-notatverktøyet ditt stopper på dårlig Wi-Fi; og for hele buen, anatomien i å skipe programvare til perfeksjon.