Steven
Steven6 Min. Lesezeit

Die Sicherheitsprüfung, die unsere App unmöglich zu beenden machte

Das automatische Update war das schwerste Feature, das wir je ausgeliefert haben — sechs Releases in vier Tagen, damit es die App nicht mehr unbrauchbar machte. Der schlimmste Bug war einer, den wir aus lauter Vorsicht selbst eingebaut haben: eine 500-Millisekunden-„Sicherheits"-Prüfung, die aus einem fehlgeschlagenen Update einen Prozess machte, den man buchstäblich nicht beenden konnte.

Engineering
Electron
Zuverlässigkeit
GeekBye-Releases
Die Sicherheitsprüfung, die unsere App unmöglich zu beenden machte

Jeder Desktop-App-Entwickler unterschätzt das automatische Update genau einmal. Es sieht aus wie ein gelöstes Problem — eine Bibliothek lädt eine neue Version herunter und startet deine App neu. Dann lieferst du es aus und lernst, dass „starte deine App neu" eine der gefährlichsten Aufgaben ist, die man einem Programm stellen kann, denn es passiert genau in dem Moment, in dem sich deine App gerade selbst abbaut und die geringste Fehlermarge hat.

Das automatische Update von GeekBye brauchte sechs Releases in vier Tagen — v1.5.14 bis v1.5.19 —, um sich zu stabilisieren. Dies ist die Geschichte des schlimmsten Bugs in diesem Abschnitt, den wir aus lauter Vorsicht selbst verursacht haben.

Sechs Releases, und der eine, der zählte

Der Bogen begann unspektakulär. v1.5.14 behob einen peinlichen Tippfehler-Bug: der Update-Feed zeigte auf einen GitHub-Repo-Namen, der nicht existierte, also fragte der Updater einen 404 ab. v1.5.15 fügte einen manuellen „Check for Updates"-Button und eine echte Fehlermeldung hinzu. Dann begannen die quitAndInstall-Bugs, und die Releases kamen schnell — denn wenn dein Update-Mechanismus kaputt ist, kannst du seine Behebung nicht über den Update-Mechanismus ausliefern. Jede Iteration ist ein manuelles Neuinstallations-Glücksspiel.

Der eine, der zählt, ist v1.5.18. Sein gesamter Inhalt war ein einziger Commit mit einem Titel, bei dem ich noch immer zusammenzucke: ursprüngliches quitAndInstall-Verhalten wiederherstellen, um eine unbeendbare App zu verhindern.

Wie „vorsichtig sein" die App unbrauchbar machte

Hier der Aufbau. Wenn ein Update heruntergeladen ist, soll Electrons quitAndInstall die App schließen und die neue Version einsetzen. In einem früheren Release sorgte sich jemand — nachvollziehbar —, dass ein bedingungsloses Beenden riskant sei. Was, wenn die Installation einen Fehler warf? Wäre es nicht sicherer, nur zu beenden, wenn alles gesund aussah?

Also bekam der Code eine Wache, die vernünftig aussah:

autoUpdater.quitAndInstall(false, true)
setTimeout(() => {
  if (this.updateDownloaded)
    app.quit() // only quit if the update is still "good"
  else console.log('Error detected — keeping app open')
}, 500)

Die Logik: löse die Installation aus, warte eine halbe Sekunde, und erzwinge das finale app.quit() nur, wenn das updateDownloaded-Flag noch wahr ist — andernfalls halte die App offen, damit der Nutzer nicht im Regen steht.

Die Falle ist eine Zeile weiter, im Fehler-Handler. Dieser Handler setzte this.updateDownloaded = false. Stell dir also eine fehlgeschlagene Installation vor: das error-Ereignis feuert und löscht das Flag. Aber quitAndInstall hatte den Abbau bereits begonnen — es hatte die Fenster geschlossen und die Beenden-Listener der App entfernt. Dann wacht der 500ms-Timer auf, prüft das nun falsche Flag, entscheidet „Fehler erkannt, App offen halten" und überspringt app.quit().

Jetzt hast du, speziell unter macOS, den denkbar schlimmsten Zustand. macOS beendet eine App nicht, nur weil ihr letztes Fenster geschlossen wurde — das ist das window-all-closed-Verhalten, auf das sich jede Mac-App verlässt. Der Prozess lebt also noch, aber er hat kein Fenster, keinen Weg über die Menüleiste, und seine Beenden-Listener sind herausgerissen. Es gibt nichts anzuklicken. Cmd-Q hat niemanden, mit dem es reden kann. Der einzige Ausweg ist Force Quit über den Activity Monitor. Die „Sicherheits"-Prüfung hatte ein fehlgeschlagenes Update — ein behebbares Ärgernis — in einen Zombie verwandelt, den man nicht töten konnte.

Die Lösung: der Abbau muss bedingungslos sein

Die Korrektur in v1.5.18 ist geradezu aggressiv langweilig, und genau das ist der Punkt. Sie entfernt die Cleverness:

  1. Die window-all-closed- und before-quit-Listener entfernen, die stören könnten.
  2. Jedes Fenster zerstörenwindow.destroy(), nicht window.close(). Das Schließen kann von einem Handler abgelehnt werden; das Zerstören nicht. Wenn du dich zum Herunterfahren verpflichtest, fragst du nicht höflich.
  3. quitAndInstall aufrufen.
  4. app.quit() bedingungslos aufrufen.

Kein Flag, kein Timer, kein „lass es sicherheitshalber offen". Denn die Wahrheit über einen Herunterfahr-Pfad ist, dass ein halb erledigtes Herunterfahren schlimmer ist als jedes der beiden Ergebnisse. Vollständig beenden ist in Ordnung. Vollständig offen bleiben ist in Ordnung. Der eine Zustand, den du nie erreichen darfst, ist abgebaut, aber noch laufend — und genau das ist der Zustand, in dem dich ein bedingtes Beenden stranden lassen kann.

Zwei weitere Lektionen, die dieselbe Woche lehrte

Der unbeendbare Bug ist die Schlagzeile, aber der Sechs-Release-Sturm härtete zwei weitere Gewohnheiten ab, die es zu stehlen lohnt.

Filtere deine Absturz-Telemetrie nach exakten Signaturen, nicht nach breiten Schlagwörtern. Mitten im Sturm stellten wir fest, dass unser Fehler-Reporting so konfiguriert war, dass es alles verwarf, was Wörter wie permission, token oder microphone enthielt — ein Versuch, Rauschen zu reduzieren, der still und leise echte Abstürze verschluckte, die zufällig diese Wörter erwähnten. Wir rissen die pauschalen Filter heraus und ersetzten sie durch exakte Ablehnungs-Strings (die konkrete Meldung, die macOS ausgibt, wenn eine Berechtigung verweigert wird) und spezifische transiente Netzwerkcodes wie ERR_NETWORK_CHANGED. Rauschreduzierung und Bug-Verstecken sind derselbe Regler, nur in entgegengesetzte Richtungen gedreht; wenn du nach Bauchgefühl filterst, filterst du genau das heraus, was du sehen musstest.

Jeder automatische Pfad braucht einen manuellen Notausgang. Das automatische Update ist von Natur aus Best-Effort — Netzwerke schwächeln, Installationen scheitern. Also bekam jeder Fehlermodus einen menschlichen Rückfallweg: den manuellen „Check for Updates"-Button, Wiederholungen mit exponentiellem Backoff, einen erneuten Prüf-Timer und — eigens für den Fall reserviert, dass der manuelle Versuch eines Nutzers scheiterte — eine klar formulierte Meldung „lösche die App und installiere sie von der Website neu". Der automatische Pfad ist eine Bequemlichkeit; der manuelle Pfad ist die Garantie.

Das Fazit

  1. Eine Wache um eine unumkehrbare Aktion ist gefährlicher als die Aktion selbst. Das bedingte Beenden versuchte zu verhindern, dass ein schlechtes Update die App beendet, und schuf stattdessen einen Zustand, der schlimmer ist als Beenden oder Nicht-Beenden. Herunterfahr- und Installationspfade sollten bedingungslos und idempotent sein — niemals an ein veränderliches Flag gekoppelt, das ein anderer Handler dir unter den Füßen wegdrehen kann.
  2. Unter macOS ist „keine Fenster" nicht „keine App". Jede Abbau-Logik muss die Plattform berücksichtigen, auf der ein fensterloser Prozess weiterläuft. Teste den Fehlerpfad, auf dem echten Betriebssystem, nicht nur den glücklichen Pfad.
  3. Das Feature, das du über das Update-System auslieferst, kannst du nicht über das Update-System testen. Diese Asymmetrie ist der Grund, warum das automatische Update paranoiden, bedingungslosen, gründlich von Hand verifizierten Code verdient. Du darfst es erst dann auf die einfache Weise reparieren, nachdem es bereits funktioniert.

Dies ist das früheste Kapitel der Zuverlässigkeitsarbeit, aus der schließlich GeekBye v2 wurde. Wohin dieser Weg führte, siehst du in was eine Version 2 wirklich kostet (v2.0.0) und den ganzen Bogen in die Anatomie, Software zur Perfektion auszuliefern.