Steven
Steven7 Min. Lesezeit

Geboren und behoben in zwölf Minuten

Das Changelog von GeekBye v1.8.10 sagt, es habe einen Crash beim Bearbeiten von Tastaturkürzeln behoben. Hat es — aber der Crash wurde im selben Pull Request eingeführt und behoben, zwölf Minuten auseinander, und erreichte keinen einzigen Nutzer. Die eigentliche Geschichte ist die Zuverlässigkeitskaskade, die ihn hervorbrachte: eine kleine, korrekte Änderung daran, wann Kürzel suspendiert werden, und der React-Teardown-Bug, der aus einer einzigen Zeile fiel, die den Editor sicherer machen sollte.

Technik
React
Desktop
GeekBye-Releases
Geboren und behoben in zwölf Minuten

Hier ist der ehrlichste Satz, den ich über GeekBye v1.8.10 schreiben kann: der Crash, für dessen Behebung sie berühmt ist, passierte niemandem. Das Changelog sagt "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit", was stimmt, und liest sich wie ein Bug, der ausgeliefert wurde, Nutzer biss, und in einem Folge-Patch behoben wurde. Wurde er nicht. Der Crash wurde von einem Commit eingeführt und von einem anderen im selben Pull Request gelöscht, zwölf Minuten auseinander, am selben Freitagmorgen. Er lebte vollständig innerhalb eines Branches. Die Release-Notes beschreiben eine Narbe, die sich bildete und heilte, bevor irgendjemand außerhalb des Repos sie sehen konnte.

Das ist kein Versagen des Changelogs; es ist die ehrliche Form iterativer Arbeit, und es ist erzählenswert, weil die zwölf Minuten einen wirklich lehrreichen React-Bug enthalten. Aber um den Crash zu verstehen, musst du verstehen, wofür das Release eigentlich da war — denn der Crash war Kollateralschaden beim Beheben von etwas anderem.

Worum es beim Release eigentlich ging

GeekBye lässt dich seine Tastaturkürzel in den Einstellungen neu belegen. Während du eine neue Kombination aufnimmst, muss die App verhindern, dass ihre globalen Kürzel feuern — sonst würde Cmd+B zu drücken, um es zuzuweisen, nur das Fenster umschalten, statt aufgezeichnet zu werden. Also suspendiert der Editor die globalen Kürzel über IPC: der Renderer ruft window.electronAPI.setShortcutsSuspended(true) auf, und der ShortcutsHelper des Hauptprozesses antwortet, indem er unregisterAll() aufruft und jede globalShortcut-Registrierung fallen lässt, damit der Tastendruck den Aufnahme-Input erreicht, statt eine Aktion auszulösen. Beim Fortsetzen ruft er registerGlobalShortcuts() auf, um sie zurückzusetzen.

Der Bug, der all das anstieß (issue #233), betraf den Geltungsbereich dieser Suspendierung. Der alte Code suspendierte die Kürzel die ganze Zeit, in der die Kürzel-Einstellungsseite offen war — nicht nur, während du aktiv eine Taste aufnahmst. Meistens ist das unsichtbar. Aber wenn du die Seite öffnetest, etwas anderes anfingst, und wegnavigiertest, ohne je eine Bearbeitung abzuschließen, konnte die App mit all ihren globalen Kürzeln nicht registriert zurückbleiben — still tot — bis du zurückgingst. Der Commit, der es behebt, ist offen über das Symptom: zu breit zu suspendieren riskierte "making the app appear broken if the user forgot they had the page open."

Der Fix ist eine einzeilige Verengung: nur suspendieren, wenn eine Zeile tatsächlich bearbeitet wird — verschlüsselt auf !!editingShortcut, dem Hook-State, der die id der Zeile hält, die du aufnimmst — statt für den ganzen Besuch. Gute Änderung. Aber „nur während der Bearbeitung suspendieren“ wirft eine sofortige Frage auf: was zählt als eine Bearbeitung zu beenden? Eine Taste zu drücken beendet sie. Escape zu drücken bricht sie ab. Aber was, wenn du einfach… wegklickst, oder mitten in der Aufnahme komplett zu einer anderen App wechselst? Wenn nichts die Bearbeitung abbricht, nimmst du ein Kürzel in ein Fenster auf, das nicht fokussiert ist, und die Kürzel bleiben suspendiert. Also fügte derselbe PR ein Sicherheitsventil hinzu: die Bearbeitung abbrechen, wenn der Fokus geht. Und das ist die Zeile, die abstürzte.

Der Zwölf-Minuten-Bug

Das Sicherheitsventil war ein useEffect in ShortcutsSettings.tsx, das, während eine Zeile bearbeitet wurde, auf Blur lauschte:

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

Zwei Listener. Einer am window — feuert, wenn du zu einer anderen App wechselst. Einer direkt, als roher DOM-Listener, an das Aufnahme-<input> gehängt — gedacht, um zu feuern, wenn du zu einem anderen Element wegklickst. Sie sehen redundant und harmlos aus. Sind sie nicht.

Geh die Sequenz für „switching windows mid-edit“ durch, was genau die Formulierung des Changelogs ist:

  1. Du nimmst ein Kürzel auf; das <input> ist fokussiert. Du machst Cmd+Tab zu einer anderen App.
  2. Der Fenster-Blur feuert. handleBlur führt cancelEditing() aus, das editingShortcut zurück auf null setzt.
  3. Diesen State zu setzen unmountet das <input> — die Zeile verlässt den Bearbeitungsmodus, also wird der Input aus dem DOM entfernt.
  4. Ein fokussiertes Element aus dem DOM zu entfernen löst synchron ein blur-Event auf diesem Element aus. Der rohe inputRef-Listener fängt es und ruft cancelEditing() erneut auf — diesmal mitten in Reacts Commit-Phase, während der Komponentenbaum abgebaut wird.
  5. Einen State-Setter auf einem Fiber mitten im Teardown aufzurufen stolpert über eines von Reacts internen Invarianten, und es wirft: "Should have a queue". Die Einstellungsansicht stürzt ab.

Der Bug ist nicht der Fenster-Listener und nicht wirklich das Konzept „bei Blur abbrechen“. Es ist, dass einer der beiden Listener ein roher addEventListener auf einem von React verwalteten Element war. React weiß nichts von diesem Listener, räumt ihn beim Unmount nicht auf, und — entscheidend — der Browser feuert Blur genau während der DOM-Entfernung, die React durchführt, sodass der Handler zum schlimmstmöglichen Augenblick erneut in deine State-Logik eintritt. Die Nachricht des Fix-Commits sagt es präziser, als ich es kann: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."

Der Fix

bf28a50, zwölf Minuten nachdem der Crash geboren wurde, tut zwei kleine Dinge.

Erstens löscht er den rohen Input-Listener und behält nur den des Fensters, der der einzige war, der überhaupt ein manueller DOM-Listener sein musste (es gibt kein React-onBlur für „das ganze Fenster hat den Fokus verloren“):

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

Zweitens verschiebt er den Klick-weg-Fall — „Nutzer klickte ein anderes Element an“ — auf Reacts eigene onBlur-Prop am Input, und schiebt ihn um einen Tick auf:

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

Beide Teile zählen. Reacts onBlur-Prop statt eines rohen Listeners zu nutzen heißt, React besitzt den Lebenszyklus des Handlers und lässt ihn nicht baumeln. Und das setTimeout(…, 0) garantiert, dass selbst wenn dieser Blur von einem Unmount ausgelöst wird, der cancelEditing()-Aufruf im nächsten Tick landet — nachdem der aktuelle Render und Commit fertig sind — sodass er nie State auf einer Komponente aktualisieren kann, die noch abgebaut wird. Ist die Komponente bis dahin schon weg, ist der aufgeschobene Aufruf ein harmloser No-op.

Das ist der ganze Fix. Lösche den Listener, den React nicht sehen konnte; lass React den verbleibenden einplanen.

Warum das die gute Sorte langweilig ist

Es gibt eine Versuchung, das unter „trivial“ abzulegen — ein setTimeout, eine gelöschte Zeile — und weiterzuziehen. Aber seine Form ist eine der häufigsten Arten, wie React-Desktop-Apps abstürzen, und es lohnt sich, sie zu verinnerlichen:

  • Der Crash erscheint nur an einer Lebenszyklus-Grenze (Unmount), ausgelöst von einem Event, das das Framework nicht einplant (ein nativer Blur während der DOM-Entfernung). Er taucht in einem Klick-Test nicht auf; er braucht den spezifischen Pfad „der Fokus geht während der Bearbeitung“.
  • Die Grundursache ist das Mischen zweier Besitzmodelle — rohe DOM-Events und Reacts synthetische, eingeplante Events — auf demselben Element. In dem Moment, in dem dein Handler State mutiert, der dieses Element unmounten kann, wird der rohe Pfad reentrant.
  • Der Fix ist nicht „ein Guard-Flag hinzufügen“ (obwohl ein isMounted-Ref es maskiert hätte); es ist das komplette Entfernen des rohen Listeners, was die kleinere Änderung ist und die richtige. Subtraktion, wieder.

Und die Meta-Lektion, die, um die diese ganze Serie kreist: lies den Diff, nicht die Release-Note. "Fixed a crash while editing keyboard shortcuts" ist zutreffend, aber es verbirgt, dass der Crash selbst zugefügt war, vom selben Morgen, und nie ausgeliefert wurde — und dass das eigentliche Release ein leiser Korrektheitsgewinn darüber ist, wann ein globales Kürzel zu suspendieren ist.

Was sonst noch ausgeliefert wurde

Zwei weitere Dinge fuhren in v1.8.10 mit, beide eine Zeile wert. Das Kürzelsystem gewann außerdem Selbstheilung über Ruhezustand/Aufwachen: macOS kann die globalen Kürzelregistrierungen einer App still fallen lassen, wenn die Maschine schläft, also verifiziert und registriert ein Resume-Handler sie jetzt neu, was heißt, deine Kürzel funktionieren nach dem Aufwachen des Laptops noch ohne Neustart. Und auf der KI-Seite wurde der Client klüger bei Rate Limits — Streaming-Anfragen, die auf ein 429 treffen, machen jetzt einen exponentiellen Back-off und setzen ihren Zähler zurück, wenn du die Aufnahme stoppst, statt eine feste Verzögerung abzuwarten. Drei Zuverlässigkeitsfixes an verschiedenen Subsystemen, einer davon der Crash, der nie ein Crash war.

Für das vorige Kapitel der v1-Geschichte, ein Meeting ohne PDF-Bibliothek als PDF drucken (v1.8.9); und für den ganzen Bogen, die Anatomie, Software bis zur Perfektion auszuliefern.

Ähnliche Artikel

Ein Meeting ohne PDF-Bibliothek als PDF drucken
Steven
Steven9 Min. Lesezeit

Ein Meeting ohne PDF-Bibliothek als PDF drucken

GeekBye exportiert ein Meeting als PDF, und nirgends im Code steckt eine PDF-Bibliothek. Es rendert HTML in einem unsichtbaren Browserfenster und druckt es. Diese Wahl ist die ganze Geschichte: sie machte das Feature leicht zu bauen und gab ihm jede Störung, die ein echter Browser hat — ein weißes Aufblitzen, ein URL-Längenlimit und einen Seitenumbruch, der Screenshots in der Mitte durchschnitt. Der Fix für den hässlichsten Bug war eine einzige Zeile CSS.

Technik
Electron
Desktop
Von OCR zu Pixeln, ohne den Fallback zu verlieren
Steven
Steven8 Min. Lesezeit

Von OCR zu Pixeln, ohne den Fallback zu verlieren

GeekBye hörte auf, deine Screenshots per OCR zu lesen, und begann, das echte Bild an Vision-Modelle zu schicken — was der leichte Teil ist. Der lehrreiche Teil ist die Optimierung, die OCR für Vision-Nutzer komplett übersprang, den Fallback zu Nicht-Vision-Modellen still zerbrach und bis zu einer Version zurückgenommen werden musste, in der das Sicherheitsnetz parallel läuft und nichts kostet.

Technik
AI
Desktop
Die zwei Fehlermodi eines Click-Through-Overlays
Steven
Steven9 Min. Lesezeit

Die zwei Fehlermodi eines Click-Through-Overlays

GeekByes Fenster schwebt über allem und lässt deine Klicks hindurchfallen — außer dort, wo es Buttons hat. Das ist ein zweiseitiger Vertrag, und v1.8.5 und v1.8.14 sind, wie es aussieht, wenn jede Seite bricht: ein Release, in dem das Overlay einen Systemdialog verschluckte, eines, in dem es deine Tastenanschläge stahl. Der siegreiche Fix für den zweiten war, Code zu löschen.

Technik
Electron
Desktop