
Ο έλεγχος ασφαλείας που έκανε την εφαρμογή μας αδύνατο να κλείσει
Το auto-update ήταν το πιο δύσκολο feature που κυκλοφορήσαμε ποτέ — έξι εκδόσεις σε τέσσερις μέρες για να σταματήσει να αχρηστεύει την εφαρμογή. Το χειρότερο bug ήταν αυτό που εισαγάγαμε προσπαθώντας να είμαστε προσεκτικοί: ένας έλεγχος "ασφαλείας" 500 χιλιοστών του δευτερολέπτου που μετέτρεψε μια αποτυχημένη ενημέρωση σε μια διεργασία που κυριολεκτικά δεν μπορούσες να κλείσεις.
Κάθε προγραμματιστής desktop εφαρμογών υποτιμά το auto-update ακριβώς μία φορά. Μοιάζει με λυμένο πρόβλημα — μια βιβλιοθήκη κατεβάζει μια νέα έκδοση και επανεκκινεί την εφαρμογή σου. Μετά την κυκλοφορείς, και μαθαίνεις ότι το "επανεκκίνησε την εφαρμογή σου" είναι ένα από τα πιο επικίνδυνα πράγματα που μπορεί να ζητηθούν από ένα πρόγραμμα, γιατί συμβαίνει την ακριβή στιγμή που η εφαρμογή σου γκρεμίζει τον εαυτό της και έχει το λιγότερο περιθώριο για λάθος.
Το auto-update του GeekBye χρειάστηκε έξι εκδόσεις σε τέσσερις μέρες — v1.5.14 έως v1.5.19 — για να σταθεροποιηθεί. Αυτή είναι η ιστορία του χειρότερου bug σε εκείνο το διάστημα, το οποίο προκαλέσαμε μόνοι μας προσπαθώντας να είμαστε προσεκτικοί.
Έξι εκδόσεις, και αυτή που μετρούσε
Η ιστορία ξεκίνησε πεζά. Η v1.5.14 διόρθωσε ένα ντροπιαστικό bug τύπου τυπογραφικού λάθους: το update feed έδειχνε σε ένα όνομα repo του GitHub που δεν υπήρχε, οπότε ο updater έλεγχε ένα 404. Η v1.5.15 πρόσθεσε ένα χειροκίνητο κουμπί "Check for Updates" και ένα πραγματικό μήνυμα σφάλματος. Μετά ξεκίνησαν τα bugs του quitAndInstall, και οι εκδόσεις έρχονταν γρήγορα — γιατί όταν ο μηχανισμός ενημέρωσής σου είναι χαλασμένος, δεν μπορείς να κυκλοφορήσεις τη διόρθωσή του μέσω του μηχανισμού ενημέρωσης. Κάθε επανάληψη είναι ένα στοίχημα χειροκίνητης επανεγκατάστασης.
Αυτή που μετράει είναι η v1.5.18. Όλο της το περιεχόμενο ήταν ένα μόνο commit με έναν τίτλο που ακόμα με κάνει να μορφάζω: restore original quitAndInstall behavior to prevent unkillable app.
Πώς το "να είμαστε προσεκτικοί" αχρήστευσε την εφαρμογή
Να το σκηνικό. Όταν μια ενημέρωση κατεβαίνει, το quitAndInstall του Electron υποτίθεται ότι κλείνει την εφαρμογή και εγκαθιστά τη νέα έκδοση. Σε μια προηγούμενη έκδοση, κάποιος — λογικά — ανησύχησε ότι το να κλείνει η εφαρμογή ανεπιφύλακτα ήταν ριψοκίνδυνο. Κι αν η εγκατάσταση απέτυχε; Δεν θα ήταν ασφαλέστερο να κλείνει μόνο αν όλα έμοιαζαν υγιή;
Έτσι ο κώδικας απέκτησε μια προστασία που έμοιαζε λογική:
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)
Η λογική: ενεργοποίησε την εγκατάσταση, περίμενε μισό δευτερόλεπτο, και επίβαλε το τελικό app.quit() μόνο αν η σημαία updateDownloaded είναι ακόμα αληθής — αλλιώς κράτα την εφαρμογή ανοιχτή ώστε ο χρήστης να μη μείνει στα κρύα του λουτρού.
Η παγίδα είναι μία γραμμή παρακάτω, στον handler του error. Εκείνος ο handler όριζε this.updateDownloaded = false. Οπότε φαντάσου μια αποτυχημένη εγκατάσταση: το event error ενεργοποιείται και καθαρίζει τη σημαία. Αλλά το quitAndInstall είχε ήδη ξεκινήσει το teardown — είχε κλείσει τα παράθυρα και είχε αφαιρέσει τους quit listeners της εφαρμογής. Μετά ο timer των 500ms ξυπνά, ελέγχει τη σημαία που είναι πλέον ψευδής, αποφασίζει "error detected, keep the app open," και παραλείπει το app.quit().
Τώρα έχεις, στο macOS συγκεκριμένα, τη χειρότερη δυνατή κατάσταση. Το macOS δεν κλείνει μια εφαρμογή απλώς επειδή έκλεισε το τελευταίο της παράθυρο — αυτή είναι η συμπεριφορά window-all-closed στην οποία βασίζεται κάθε εφαρμογή Mac. Οπότε η διεργασία είναι ακόμα ζωντανή, αλλά έχει κανένα παράθυρο, κανένα μονοπάτι στη γραμμή μενού, και οι quit listeners της έχουν ξεριζωθεί. Δεν υπάρχει τίποτα να πατήσεις. Το Cmd-Q δεν έχει με ποιον να μιλήσει. Ο μόνος τρόπος εξόδου είναι το Force Quit από το Activity Monitor. Ο έλεγχος "ασφαλείας" είχε μετατρέψει μια αποτυχημένη ενημέρωση — μια ανακτήσιμη ενόχληση — σε ένα ζόμπι που δεν μπορούσες να σκοτώσεις.
Η λύση: το teardown πρέπει να είναι ανεπιφύλακτο
Η διόρθωση στην v1.5.18 είναι σχεδόν επιθετικά βαρετή, που είναι και το ζητούμενο. Αφαιρεί την εξυπνάδα:
- Αφαίρεσε τους listeners
window-all-closedκαιbefore-quitπου θα μπορούσαν να παρέμβουν. - Κατάστρεψε κάθε παράθυρο —
window.destroy(), όχιwindow.close(). Το close μπορεί να μπλοκαριστεί από έναν handler· το destroy δεν μπορεί. Όταν δεσμεύεσαι να τερματίσεις, δεν το ζητάς ευγενικά. - Κάλεσε το
quitAndInstall. - Κάλεσε το
app.quit()ανεπιφύλακτα.
Καμία σημαία, κανένας timer, κανένα "κράτα το ανοιχτό για καλό και για κακό." Γιατί η αλήθεια για ένα μονοπάτι τερματισμού είναι ότι ένας μισοτελειωμένος τερματισμός είναι χειρότερος από οποιοδήποτε από τα δύο αποτελέσματα. Ο πλήρης τερματισμός είναι εντάξει. Το να μείνει πλήρως ανοιχτή είναι εντάξει. Η μόνη κατάσταση που δεν πρέπει ποτέ να φτάσεις είναι γκρεμισμένη αλλά ακόμα σε λειτουργία — και αυτή ακριβώς είναι η κατάσταση στην οποία μπορεί να σε αφήσει ένα υπό όρους quit.
Δύο ακόμη μαθήματα που δίδαξε η ίδια εβδομάδα
Το bug που δεν κλείνει είναι ο τίτλος, αλλά η εξάωρη — έξι εκδόσεων — φούρια σκλήρυνε δύο ακόμη συνήθειες που αξίζει να κλέψεις.
Φιλτράρισε την τηλεμετρία crash με ακριβείς υπογραφές, όχι με πλατιές λέξεις-κλειδιά. Μέσα στη φούρια ανακαλύψαμε ότι η αναφορά σφαλμάτων μας ήταν ρυθμισμένη να απορρίπτει οτιδήποτε περιείχε λέξεις όπως permission, token, ή microphone — μια προσπάθεια να μειώσουμε τον θόρυβο που σιωπηλά κατάπινε πραγματικά crashes που τύχαινε να αναφέρουν αυτές τις λέξεις. Ξηλώσαμε τα ασπρόμαυρα φίλτρα και τα αντικαταστήσαμε με ακριβή strings άρνησης (το συγκεκριμένο μήνυμα που εκπέμπει το macOS όταν μια άδεια απορρίπτεται) και συγκεκριμένους κωδικούς παροδικών δικτυακών σφαλμάτων όπως το ERR_NETWORK_CHANGED. Η μείωση θορύβου και η απόκρυψη bug είναι το ίδιο κουμπί γυρισμένο σε αντίθετες κατευθύνσεις· αν φιλτράρεις με το ένστικτο, θα φιλτράρεις έξω αυτό ακριβώς που χρειαζόσουν να δεις.
Κάθε αυτόματο μονοπάτι χρειάζεται μια χειροκίνητη έξοδο διαφυγής. Το auto-update είναι εκ φύσεως best-effort — τα δίκτυα κάνουν κόλπα, οι εγκαταστάσεις αποτυγχάνουν. Έτσι κάθε τρόπος αποτυχίας απέκτησε ένα ανθρώπινο fallback: το χειροκίνητο κουμπί "Check for Updates", επαναλήψεις με exponential backoff, έναν timer επανελέγχου, και — κρατημένο ειδικά για την περίπτωση όπου η χειροκίνητη προσπάθεια ενός χρήστη απέτυχε — ένα μήνυμα σε απλή γλώσσα "διάγραψε την εφαρμογή και επανεγκατέστησέ την από την ιστοσελίδα." Το αυτόματο μονοπάτι είναι μια ευκολία· το χειροκίνητο μονοπάτι είναι η εγγύηση.
Το συμπέρασμα
- Μια προστασία γύρω από μια μη αναστρέψιμη ενέργεια είναι πιο επικίνδυνη από την ίδια την ενέργεια. Το υπό όρους quit προσπάθησε να αποτρέψει μια κακή ενημέρωση από το να κλείσει την εφαρμογή, και αντ' αυτού δημιούργησε μια κατάσταση χειρότερη είτε από το κλείσιμο είτε από τη μη ενημέρωση. Τα μονοπάτια τερματισμού και εγκατάστασης πρέπει να είναι ανεπιφύλακτα και idempotent — ποτέ να μην εξαρτώνται από μια μεταβλητή σημαία που ένας διαφορετικός handler μπορεί να αλλάξει πίσω από την πλάτη σου.
- Στο macOS, το "κανένα παράθυρο" δεν σημαίνει "καμία εφαρμογή." Οποιαδήποτε λογική teardown πρέπει να λαμβάνει υπόψη την πλατφόρμα όπου μια διεργασία χωρίς παράθυρο συνεχίζει να τρέχει. Δοκίμασε το μονοπάτι αποτυχίας, στο πραγματικό OS, όχι μόνο το ευτυχές μονοπάτι.
- Το feature που κυκλοφορείς μέσω του συστήματος ενημέρωσης δεν μπορεί να δοκιμαστεί μέσω του συστήματος ενημέρωσης. Αυτή η ασυμμετρία είναι ο λόγος που το auto-update αξίζει παρανοϊκό, ανεπιφύλακτο, βαριά χειροκίνητα επαληθευμένο κώδικα. Έχεις τη δυνατότητα να το διορθώσεις με τον εύκολο τρόπο μόνο αφού ήδη λειτουργεί.
Αυτό είναι το πρώιμο κεφάλαιο της δουλειάς αξιοπιστίας που τελικά έγινε το GeekBye v2. Για το πού οδήγησε αυτός ο δρόμος, δες τι πραγματικά χρειάζεται μια δεύτερη έκδοση (v2.0.0) και όλη την ιστορία στο η ανατομία του να κυκλοφορείς λογισμικό στην τελειότητα.