Steven
Steven8 min leestijd

Geboren en gefixt in twaalf minuten

De changelog van GeekBye v1.8.10 zegt dat het een crash verhielp tijdens het bewerken van sneltoetsen. Dat klopt — maar de crash werd geïntroduceerd en verholpen in dezelfde pull request, twaalf minuten uit elkaar, en bereikte nooit een enkele gebruiker. Het echte verhaal is de betrouwbaarheidscascade die hem voortbracht: een kleine, correcte wijziging aan wanneer sneltoetsen worden opgeschort, en de React-teardown-bug die uit één regel viel die bedoeld was om de editor veiliger te maken.

Engineering
React
Desktop
GeekBye Releases
Geboren en gefixt in twaalf minuten

Hier is de eerlijkste zin die ik over GeekBye v1.8.10 kan schrijven: de crash die het beroemd verhielp, is nooit bij iemand gebeurd. De changelog zegt "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit," wat waar is, en leest als een bug die werd uitgebracht, gebruikers beet, en in een vervolg werd gepatcht. Dat was niet zo. De crash werd door de ene commit geïntroduceerd en door de andere verwijderd in dezelfde pull request, twaalf minuten uit elkaar, op dezelfde vrijdagochtend. Hij leefde volledig binnen een branch. De release notes beschrijven een litteken dat zich vormde en genas voordat iemand buiten de repo het kon zien.

Dat is geen falen van de changelog; het is de eerlijke vorm van iteratief werk, en het is het vertellen waard omdat die twaalf minuten een oprecht leerzame React-bug bevatten. Maar om de crash te begrijpen moet je begrijpen waar de release eigenlijk voor was — want de crash was bijkomende schade van het fixen van iets anders.

Waar de release eigenlijk over ging

Met GeekBye kun je zijn sneltoetsen opnieuw instellen in Instellingen. Terwijl je een nieuwe combinatie opneemt, moet de app voorkomen dat zijn globale sneltoetsen afvuren — anders zou het indrukken van Cmd+B om die toe te wijzen gewoon het venster togglen in plaats van te worden opgevangen. Dus de editor schort de globale sneltoetsen op via IPC: de renderer roept window.electronAPI.setShortcutsSuspended(true) aan, en de ShortcutsHelper van het main-proces reageert door unregisterAll() aan te roepen, waardoor elke globalShortcut-registratie wordt gedropt zodat de toetsaanslag de opname-input bereikt in plaats van een actie te triggeren. Bij hervatten roept het registerGlobalShortcuts() aan om ze terug te zetten.

De bug waar dit allemaal mee begon (issue #233) ging over de reikwijdte van die opschorting. De oude code schortte de sneltoetsen op gedurende de hele tijd dat de sneltoetsen-instellingenpagina open was — niet alleen terwijl je actief een toets opnam. Meestal is dat onzichtbaar. Maar als je de pagina opende, iets anders ging doen, en wegnavigeerde zonder ooit een bewerking af te ronden, kon de app achterblijven met al zijn globale sneltoetsen niet-geregistreerd — stilletjes dood — totdat je terugging. De commit die het verhelpt is openhartig over het symptoom: te breed opschorten riskeerde "making the app appear broken if the user forgot they had the page open."

De fix is een versmalling van één regel: schort alleen op wanneer een rij daadwerkelijk wordt bewerkt — gekoppeld aan !!editingShortcut, de hook-state die de id bevat van de rij die je opneemt — in plaats van gedurende het hele bezoek. Goede wijziging. Maar "alleen opschorten tijdens het bewerken" roept meteen een vraag op: wat telt als het afronden van een bewerking? Een toets indrukken rondt het af. Escape indrukken annuleert het. Maar wat als je gewoon… wegklikt, of midden in de opname volledig naar een andere app wisselt? Als niets de bewerking annuleert, blijf je een sneltoets opnemen in een venster dat niet de focus heeft, en blijven de sneltoetsen opgeschort. Dus dezelfde PR voegde een veiligheidsklep toe: annuleer het bewerken wanneer de focus vertrekt. En dat is de regel die crashte.

De twaalf-minutenbug

De veiligheidsklep was een useEffect in ShortcutsSettings.tsx die, terwijl een rij werd bewerkt, luisterde naar blur:

const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine

Twee listeners. Eén op het window — vuurt af wanneer je naar een andere app wisselt. Eén rechtstreeks aangehangen, als rauwe DOM-listener, aan de opname-<input> — bedoeld om af te vuren wanneer je wegklikt naar een ander element. Ze zien er overbodig en onschuldig uit. Dat zijn ze niet.

Loop de reeks door voor "switching windows mid-edit," precies de bewoording van de changelog:

  1. Je neemt een sneltoets op; de <input> heeft de focus. Je doet Cmd+Tab naar een andere app.
  2. De venster-blur vuurt af. handleBlur draait cancelEditing(), die editingShortcut terugzet op null.
  3. Het instellen van die state unmount de <input> — de rij verlaat de bewerkmodus, dus de input wordt uit de DOM verwijderd.
  4. Een gefocust element uit de DOM verwijderen verzendt synchroon een blur-event op dat element. De rauwe inputRef-listener vangt het op en roept cancelEditing() opnieuw aan — dit keer midden in React's commit-fase, terwijl de componentboom wordt afgebroken.
  5. Een state-setter aanroepen op een fiber die midden in de teardown zit, activeert een van React's interne invarianten, en het gooit op: "Should have a queue". De instellingenweergave crasht.

De bug is niet de venster-listener en ook niet echt het concept van "annuleren bij blur." Het is dat een van de twee listeners een rauwe addEventListener op een React-beheerd element was. React weet niets van die listener, ruimt hem niet op bij unmount, en — cruciaal — de browser vuurt blur af tijdens juist die DOM-verwijdering die React uitvoert, dus de handler gaat je state-logica opnieuw binnen op het slechtst mogelijke moment. De boodschap van de fix-commit zegt het preciezer dan ik kan: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."

De fix

bf28a50, twaalf minuten nadat de crash werd geboren, doet twee kleine dingen.

Ten eerste verwijdert het de rauwe input-listener en houdt alleen die op het venster, de enige die überhaupt een handmatige DOM-listener moest zijn (er is geen React onBlur voor "het hele venster verloor de focus"):

const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
  window.removeEventListener('blur', handleWindowBlur)
}

Ten tweede verplaatst het het klik-weg-geval — "gebruiker klikte op een ander element" — naar React's eigen onBlur-prop op de input, en stelt het een tik uit:

onBlur={() => {
  // Deferred to avoid state updates during React's render cycle.
  setTimeout(() => cancelEditing(), 0)
}}

Beide onderdelen zijn belangrijk. React's onBlur-prop gebruiken in plaats van een rauwe listener betekent dat React de levenscyclus van de handler bezit en hem niet laat bungelen. En de setTimeout(…, 0) garandeert dat zelfs wanneer deze blur wél door een unmount wordt getriggerd, de cancelEditing()-aanroep op de volgende tik landt — nadat de huidige render en commit klaar zijn — zodat hij nooit state kan bijwerken op een component die nog aan het afbreken is. Als de component tegen die tijd al weg is, is de uitgestelde aanroep een onschuldige no-op.

Dat is de hele fix. Verwijder de listener die React niet kon zien; laat React degene die overblijft inplannen.

Waarom dit het goede soort saai is

Er is een verleiding om dit onder "triviaal" te scharen — een setTimeout, een verwijderde regel — en verder te gaan. Maar de vorm ervan is een van de meest voorkomende manieren waarop React-desktopapps crashen, en het is de moeite waard om te internaliseren:

  • De crash verschijnt alleen op een levenscyclusgrens (unmount), getriggerd door een event dat het framework niet inplant (een native blur tijdens DOM-verwijdering). Het duikt niet op in een doorklik-test; het heeft het specifieke pad "focus vertrekt tijdens het bewerken" nodig.
  • De grondoorzaak is het mengen van twee eigendomsmodellen — rauwe DOM-events en React's synthetische, ingeplande events — op hetzelfde element. Op het moment dat je handler state muteert die dat element kan unmounten, wordt het rauwe pad re-entrant.
  • De fix is niet "voeg een guard-vlag toe" (hoewel een isMounted-ref het zou hebben gemaskeerd); het is de rauwe listener volledig verwijderen, wat de kleinere wijziging is en de juiste. Opnieuw aftrekken.

En de metales, degene waar deze hele serie steeds omheen draait: lees de diff, niet de release note. "Fixed a crash while editing keyboard shortcuts" is accuraat, maar het verbergt dat de crash zelf-toegebracht was, dezelfde ochtend, en nooit werd uitgebracht — en dat de echte release een stille correctheidswinst is over wanneer je een globale sneltoets moet opschorten.

Wat er nog meer werd uitgebracht

Nog twee dingen liftten mee in v1.8.10, beide een regel waard. Het sneltoetssysteem kreeg ook zelfherstel over slaap/ontwaken: macOS kan de globale sneltoets-registraties van een app stilletjes laten vallen wanneer de machine slaapt, dus een resume-handler verifieert en herregistreert ze nu, wat betekent dat je sneltoetsen nog werken nadat de laptop ontwaakt zonder een herstart. En aan de AI-kant werd de client slimmer met rate limits — streamingverzoeken die een 429 raken, backen nu exponentieel af en resetten hun teller wanneer je stopt met opnemen, in plaats van een vaste vertraging uit te zitten. Drie betrouwbaarheidsfixes aan verschillende subsystemen, waarvan er één de crash is die nooit een crash was.

Voor het vorige hoofdstuk in het v1-verhaal, een meeting naar PDF printen zonder PDF-bibliotheek (v1.8.9); en voor de hele boog, de anatomie van software tot in de perfectie uitbrengen.

Gerelateerde Artikelen

De drie werkwoorden die Web Audio in leven houden
Steven
Steven9 min leestijd

De drie werkwoorden die Web Audio in leven houden

Twee GeekBye-point-releases, twee maanden uit elkaar en in twee verschillende bestanden, leerden onze audiocode dezelfde les vanuit tegengestelde kanten: behandel de AudioContext van de browser niet langer als wegwerpbaar. De ene release leerde resume() aan te roepen op een context die macOS stilletjes midden in een opname had gesuspend; de andere leerde suspend() te gebruiken in plaats van close() zodat opeenvolgende sessies niet langer tegen Chromiums plafond van ongeveer zes contexten aan knallen. Resume, suspend, close — dat is het hele plot.

Engineering
Audio
Desktop
Een call onderscheiden van een open app
Steven
Steven9 min leestijd

Een call onderscheiden van een open app

GeekBye kan merken dat je aan een videovergadering bent gaan deelnemen en aanbieden om die op te nemen. De detectie blijkt de makkelijke helft — een Swift-binary die elke tien seconden vensternamen leest. De moeilijke helft is precisie: niet afgaan wanneer Zoom slechts openstaat, niet vragen om een vergadering die je al opneemt, en niet de microfoon dempen in het gesprek waar je daadwerkelijk in zit. Drie releases, en elk ervan is een waarborg die moest leren zichzelf niet te verslaan.

Engineering
macOS
Desktop
De backend uit het uploadpad halen
Steven
Steven9 min leestijd

De backend uit het uploadpad halen

GeekBye neemt je scherm op en bewaart de video in je Google Drive. De eerste versie stuurde elke opname onderweg via de eigen servers van GeekBye; een release later ging het bestand rechtstreeks van je machine naar Drive, en de backend werd gedegradeerd tot het vasthouden van één enkele pointer. Het interessante is hoe weinig code de 'directe, hervatbare' versie eigenlijk bevat — want de hervatbaarheid kwam voort uit het verwijderen van een proxy, niet uit het schrijven ervan.

Engineering
Architecture
Desktop