Τεχνικό Άρθρο

HotXLS, CRC32 του zlib-ng, και υπερχείλιση στοίβας σε νήματα Delphi

Το HotXLS μπορεί να καταρρεύσει ένα νήμα εργασίας Delphi χωρίς καμία συλλάψιμη εξαίρεση όταν κάνει άθροισμα ελέγχου σε ένα μεγάλο μέρος XML φύλλου εργασίας σε μία κλήση: το zlib-ng μεταβαίνει στον αλγόριθμό του Chorba πάνω από περίπου 119 KB εισόδου, και η γενική-C παραλλαγή εκείνου του αλγορίθμου εκχωρεί έναν πίνακα εργασίας αρκετά μεγάλο ώστε να ανατινάξει τη στοίβα του προεπιλεγμένου 1 MB νήματος. Η Delphi ποτέ δεν παίρνει ευκαιρία να αντιδράσει, επειδή μια υπερχείλιση στοίβας δεν είναι το είδος εξαίρεσης που το try/except χτίστηκε για να συλλάβει

Το HotXLS είναι μια γηγενής βιβλιοθήκη Delphi και C++Builder για ανάγνωση και εγγραφή βιβλίων εργασίας Excel, και η κατάρρευση εντοπίστηκε πίσω στον γραφέα φύλλου εργασίας του. Το πρώτο σημάδι προβλήματος ήταν ένα εισιτήριο υποστήριξης: μια νυχτερινή εργασία εξαγωγής κατέρρεε περίπου δύο φορές την εβδομάδα, πάντα στη μέση της εκτέλεσης, χωρίς διάλογο εξαίρεσης Delphi και χωρίς καταγεγραμμένο σφάλμα, απλώς μια διεργασία που εξαφανιζόταν και μια καταχώριση Windows Error Reporting που δεν οδηγούσε πουθενά χρήσιμο. Η αναπαραγωγή της στο γραφείο ήταν εντελώς άλλο ζήτημα. Μικρά βιβλία εργασίας αποθηκεύονταν μια χαρά. Μεγάλα βιβλία εργασίας αποθηκεύονταν επίσης μια χαρά, όσο η αποθήκευση έτρεχε στο κύριο νήμα με έναν debugger ήδη συνδεδεμένο. Χρειάστηκε μια πραγματική παρτίδα αρχείων μεγέθους παραγωγής να τρέξει μέσα από την πραγματική διαδρομή εξαγωγής πολλαπλών νημάτων για να φέρει την κατάρρευση σπίτι, μέχρι εκείνο το σημείο η I/O δίσκου, η πίεση μνήμης, και ένα ύποπτο πρότυπο είχαν ήδη αποκλειστεί το καθένα

Πώς μια αποθήκευση φύλλου εργασίας μετατρέπεται σε μία γιγαντιαία κλήση CRC32

Τα αρχεία XLSX είναι δοχεία ZIP, και η μορφή ZIP απαιτεί ένα άθροισμα ελέγχου CRC-32 για κάθε καταχώριση, καταγεγραμμένο τόσο στην τοπική κεφαλίδα αρχείου όσο και στον κεντρικό κατάλογο. Το HotXLS υπολογίζει εκείνο το άθροισμα ελέγχου καλώντας έναν μικρό wrapper με το όνομα ZLibCRC32, ο οποίος με τη σειρά του καλεί τη δική του ρουτίνα crc32 του zlib-ng μόλις το SaveAs έχει τελειώσει τη συναρμολόγηση του XML ενός φύλλου εργασίας στη μνήμη, και για μεγάλο χρονικό διάστημα εκείνη η κλήση μετέφερε ολόκληρο το ασυμπίεστο buffer σε μία μοναδική κλήση. Αυτός είναι ένας λογικός σχεδιασμός για ένα μικρό φύλλο εργασίας. Γίνεται μία πολύ μεγάλη κλήση τη στιγμή που ένα φύλλο είναι το είδος που καλύπτεται στον οδηγό μας για την απόδοση μεγάλων βιβλίων εργασίας στο HotXLS, όπου το XML ενός μοναδικού φύλλου συνήθως τρέχει πέρα από μερικές εκατοντάδες kilobyte πριν συμπιεστεί καθόλου

Γιατί το zlib-ng χρειάζεται ένα γιγαντιαίο buffer στοίβας για το CRC32;

Το zlib-ng δεν χρησιμοποιεί μία υλοποίηση CRC-32 για κάθε κλήση. Κάτω από ένα όριο μεγέθους διατρέχει το buffer με αναζητήσεις πίνακα και κόλπα διπλώματος που δεν χρειάζονται σημαντική επιπλέον μνήμη, και πάνω από εκείνο το όριο, περίπου 119 KB, ακριβώς 118.960 byte στο build στο οποίο συνδέεται το HotXLS, μεταβαίνει σε έναν εξειδικευμένο γρήγορο αλγόριθμο που ονομάζεται Chorba. Η γενική-C υλοποίηση εκείνης της διαδρομής ανταλλάσσει μνήμη για ταχύτητα: εκχωρεί έναν πίνακα εργασίας στη στοίβα αντί στον σωρό, με μέγεθος ώστε να κάνει τον εσωτερικό βρόχο του αλγορίθμου γρήγορο, όχι να χωρά άνετα μέσα σε όποιον προϋπολογισμό στοίβας τυχαίνει να φέρει το καλούν νήμα. Τίποτα από αυτά δεν είναι ορατό από την πλευρά του καλούντος. Μια συνάρτηση αθροίσματος ελέγχου είναι κανονικά μια κλήση φύλλου, διαβάστε μερικά byte, επιστρέψτε έναν αριθμό, καμία εκχώρηση αξιοσημείωτη, και εκείνη η υπόθεση ισχύει για τη συντριπτική πλειονότητα των κλήσεων στο zlib-ng μέχρι που ένα buffer αρκετά μεγάλο ώστε να διασχίσει το όριο Chorba μπαίνει σε μία

function BuildWorksheetPartCrc(const XmlBytes: TBytes): LongWord;
begin
  // One call over the whole worksheet XML buffer: fine for a small
  // sheet, but a large enough input pushes zlib-ng onto its Chorba
  // fast path and that path's stack-hungry scratch buffer
  Result := ZLibCRC32(0, XmlBytes[0], Length(XmlBytes));
end;

Γιατί τα νήματα εργασίας το έβλεπαν και ο διαδραστικός εντοπισμός σφαλμάτων ποτέ

Η πυροδότηση αυτής της κατάρρευσης απαιτεί δύο συνθήκες ταυτόχρονα: ένα μέρος XML φύλλου εργασίας αρκετά μεγάλο ώστε να διασχίσει το όριο Chorba του zlib-ng, και ένα νήμα που έχει μόνο τη συνηθισμένη προεπιλεγμένη στοίβα αντί για κάτι πιο ευρύχωρο. Οι εργασίες εξαγωγής παραγωγής χτυπούσαν και τα δύο. Τρέχουν ως εργασίες παρτίδας από την πλευρά διακομιστή που απλώνουν τις εγγραφές HotXLS σε μια δεξαμενή νημάτων εργασίας, καθένα φέροντας την προεπιλεγμένη στοίβα 1 MB που δεσμεύουν τα Windows εκτός αν ένας καλών ζητήσει περισσότερο, και καθένα επεξεργαζόμενο βιβλία εργασίας πελατών αρκετά μεγάλα ώστε να έχει σημασία. Ο εντοπισμός σφαλμάτων στο γραφείο δεν χτυπούσε αξιόπιστα καμία από τις δύο συνθήκες: τα δείγματα αρχείων ήταν συνήθως μικρότερα από το όριο, και οι εκτελέσεις βήμα-προς-βήμα τείνανε να συμβαίνουν στο κύριο νήμα αντί μέσα σε ένα φρεσκοδημιουργημένο νήμα εργασίας, οπότε οι δύο συνθήκες που έπρεπε να ευθυγραμμιστούν στην παραγωγή σχεδόν ποτέ δεν ευθυγραμμίζονταν στο γραφείο ενός προγραμματιστή

Κυνηγώντας μια κατάρρευση που κατηγορούσε τη λάθος συνάρτηση

Οι αναφορές κατάρρευσης που μπόρεσε να πάρει στα χέρια της η ομάδα έδειχναν σε μια θέση μέσα στη συνάρτηση deflate του zlib-ng, όχι σε κανέναν κώδικα HotXLS, και όχι προφανώς ούτε στον κώδικα CRC-32. Εκείνη η μοναδική λεπτομέρεια έστειλε το πρώτο πέρασμα της έρευνας προς τη διαδρομή συμπίεσης: μεγέθη buffer που περάστηκαν στο deflate, bit παραθύρου, επίπεδο συμπίεσης, όλοι οι συνηθισμένοι ύποπτοι για μια γηγενή κατάρρευση που προέρχεται από έναν codec. Κανένας από αυτούς δεν επιβεβαιώθηκε

Ένα παραπλανητικό ανώτατο πλαίσιο

Μια υπερχείλιση στοίβας είναι ένα παράξενο είδος κατάρρευσης να συμβολιστεί, επειδή μέχρι να αναφερθεί, ο δείκτης στοίβας έχει ήδη τρέξει πέρα από τον χώρο που ήταν δεσμευμένος για αυτόν. Ό,τι παρήγαγε εκείνη την αναφορά κατάρρευσης πιθανότατα επέλυσε τη διεύθυνση σφάλματος στο πλησιέστερο σύμβολο που μπόρεσε ακόμη να βρει, και το πλησιέστερο εξαγόμενο σημείο εισόδου που καθόταν δίπλα στον πραγματικό ένοχο έτυχε να είναι το deflate. Το πραγματικό σφάλμα καθόταν στην εκχώρηση buffer εργασίας Chorba μέσα στη διαδρομή CRC-32, μεταγλωττισμένο στην ίδια βιβλιοθήκη, αρκετά κοντά στο δυαδικό ώστε να μπερδευτεί με τη συνάρτηση που πράγματι έτρεχε

Διχοτόμηση με χρονοσημάνσεις αντί για debugger

Μια κατάρρευση που ρίχνει ολόκληρη τη διεργασία δεν αφήνει τίποτα για μια κανονική συνεδρία debugger Delphi να συλλάβει, οπότε η ομάδα κατέφυγε σε σημεία ελέγχου GetTickCount ριγμένα γύρω από κάθε ύποπτη κλήση και μια χειροκίνητη διχοτόμηση κατά μήκος της διαδρομής αποθήκευσης, στενεύοντας ποια λειτουργία ήταν σε εξέλιξη τη στιγμή που πέθανε η διεργασία. Παράλληλα με αυτό, ένα γνωστά-καλό βασικό build έτρεξε τα ίδια αρχεία παραγωγής δίπλα-δίπλα με το τρέχον, ειδικά για να αποκλείσει μια οπισθοδρόμηση στις δικές του αλλαγές εκείνου του γύρου πριν κοιτάξει παραπέρα ανάντη. Μόνο αφού και οι δύο έλεγχοι βγήκαν καθαροί η έρευνα κατέληξε σε μια εξάρτηση τρίτου μέρους που έκανε κάτι απροσδόκητο με μια απολύτως έγκυρη είσοδο

Γιατί το try/except αποτυγχάνει να συλλάβει μια υπερχείλιση στοίβας;

Μια υπερχείλιση στοίβας δεν είναι μια εξαίρεση που εγείρει ποτέ σκόπιμα ο κώδικας Delphi, και δεν παραδίδεται ούτε με τον τρόπο που τα Windows παραδίδουν μια παραβίαση πρόσβασης ή μια διαίρεση με το μηδέν. Εμφανίζεται ως σφάλμα φρουρικής σελίδας υλικού, αναφερόμενο μέσω του ίδιου μηχανισμού δομημένου χειρισμού εξαιρέσεων στον οποίο είναι χτισμένο το try/except της Delphi, αλλά ακριβώς τη στιγμή που πυροδοτείται κανονικά δεν υπάρχει χώρος στοίβας για να τρέξει έναν χειριστή, να ξετυλίξει κώδικα καθαρισμού, ή ακόμη να τελειώσει την αναφορά του σφάλματος καθαρά. Σε ένα νήμα εργασίας που φέρει μόνο την προεπιλεγμένη δέσμευση 1 MB, με ένα buffer εργασίας εκείνου του μεγέθους να έχει ήδη καταναλώσει το μεγαλύτερο μέρος του τι είχε απομείνει, δεν μένει τίποτα για να δουλέψει το runtime

procedure TExportWorker.Execute;
var
  Workbook: TXLSXWorkbook;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    try
      BuildWorksheet(Workbook);
      Workbook.SaveAs(FTargetFile);  // crashes the process here on a
                                      // large enough sheet: try/except
                                      // never gets a chance to run
    except
      on E: Exception do
        LogError('Export failed: ' + E.Message);
    end;
  finally
    Workbook.Free;
  end;
end;

Εκείνο το μπλοκ except φαίνεται σαν δίχτυ ασφαλείας, και έναντι των περισσότερων αποτυχιών είναι, αλλά δεν κάνει τίποτα εδώ. Η ομάδα το επιβεβαίωσε στην πράξη: το try/except δεν συνέλαβε τίποτα, το μπλοκ finally ποτέ δεν πήρε ούτε αξιόπιστη ευκαιρία να τρέξει, και ο χειριστής είδε μια νεκρή διεργασία χωρίς καμία καταχώριση καταγραφής σε επίπεδο εφαρμογής καθόλου, ακριβώς αυτό που περιέγραφε το αρχικό εισιτήριο υποστήριξης

Η λύση: τροφοδότηση του CRC32 σε φέτες 64 KB αντί για μία γιγαντιαία κλήση

Η λύση που διένειμε το HotXLS δεν αλλάζει τίποτα σχετικά με το ίδιο το zlib-ng και τίποτα σχετικά με το επίπεδο συμπίεσης που χρησιμοποιείται για την εγγραφή του βιβλίου εργασίας. Το ZLibCRC32 τώρα διατρέχει την είσοδο σε σταθερές φέτες 64 KB, 65536 byte η καθεμία, καλώντας το crc32 του zlib-ng μία φορά ανά φέτα και περνώντας την τρέχουσα τιμή αθροίσματος ελέγχου από τη μία κλήση στην επόμενη. Το CRC-32 είναι επαυξητικός αλγόριθμος εκ κατασκευής, οπότε ένα άθροισμα ελέγχου χτισμένο πάνω από αρκετές φέτες είναι byte-προς-byte πανομοιότυπο με ένα υπολογισμένο σε μία μοναδική κλήση πάνω από τα ίδια byte: η λύση αλλάζει πώς διαιρείται η δουλειά, όχι τι υπολογίζει

function ZLibCRC32(crc: LongWord; const buffer; count: Longint): LongWord;
const
  // 64 KB keeps every call comfortably under the Chorba threshold
  CrcChunkSize = 65536;
var
  Cursor: PByte;
  ThisChunk: Longint;
begin
  Result := crc;
  Cursor := PByte(@buffer);
  while count > 0 do
  begin
    ThisChunk := count;
    if ThisChunk > CrcChunkSize then
      ThisChunk := CrcChunkSize;
    Result := zng_crc32(Result, Cursor, Cardinal(ThisChunk));
    Inc(Cursor, ThisChunk);
    Dec(count, ThisChunk);
  end;
end;

Τίποτα σχετικά με τη γύρω κλήση SaveAs δεν χρειάστηκε να αλλάξει για να λειτουργήσει αυτό, και τίποτα σχετικά με τις καταχωρίσεις ZIP που γράφει το HotXLS δεν άλλαξε επίσης: η τιμή CRC-32 που καταλήγει στην τοπική κεφαλίδα αρχείου και στον κεντρικό κατάλογο είναι ακριβώς η τιμή που θα είχε παράξει μία γιγαντιαία κλήση, απλώς συναρμολογημένη από μικρότερα κομμάτια. Η υποβάθμιση του zlib-ng ή η επιστροφή σε μια πιο αργή, με λιγότερες εκχωρήσεις υλοποίηση CRC-32 θα είχε επίσης αποφύγει την κατάρρευση, αλλά με πραγματικό κόστος για κάθε αρχείο που ποτέ δεν πλησίασε καν το όριο εξαρχής, γι' αυτό καμία από τις δύο δεν διανεμήθηκε

Τι σημαίνει αυτό αν καλείτε το zlib-ng από τα δικά σας νήματα εργασίας

Η λειτουργία αποτυχίας υπερχείλισης στοίβας που περιγράφεται εδώ δεν έχει καμία σχέση με τα υπολογιστικά φύλλα συγκεκριμένα. Οποιαδήποτε εφαρμογή δίνει στο zlib-ng ένα μεγάλο buffer, είτε για συμπίεση, αποσυμπίεση, ή άθροισμα ελέγχου, από ένα νήμα που φέρει μόνο την προεπιλεγμένη στοίβα της πλατφόρμας μπορεί να χτυπήσει το ίδιο είδος τοίχου, επειδή η βιβλιοθήκη επιλέγει τον αλγόριθμό της βάσει μεγέθους εισόδου και ορισμένοι από εκείνους τους αλγορίθμους υποθέτουν ότι υπάρχει περίσσεια στοίβας. Δύο άμυνες λειτουργούν χωρίς να αγγίζουν το ίδιο το zlib-ng: η τροφοδότηση μεγάλων buffer σε ρουτίνες ευαίσθητες στο μέγεθος σε σταθερά κομμάτια αφαιρεί εντελώς τη συνθήκη πυροδότησης για οποιονδήποτε αλγόριθμο είναι φυσικά επαυξητικός, και όπου ο τεμαχισμός δεν είναι επιλογή, το να δώσετε στο καλούν νήμα μια στοίβα μεγαλύτερη από την προεπιλογή της πλατφόρμας είναι ο άλλος μοχλός. Οποιοδήποτε από τα δύο είναι φθηνότερο από το να μάθετε για ένα μη τεκμηριωμένο όριο μεγέθους από μια αναφορά κατάρρευσης παραγωγής που κατηγορεί τη λάθος συνάρτηση

Αυτό το συγκεκριμένο όριο παρέμεινε αόρατο μέχρι ένα αρκετά μεγάλο βιβλίο εργασίας παραγωγής να το διασχίσει στο λάθος είδος νήματος, κάτι που είναι ακριβώς το είδος αποτυχίας που εμφανίζεται μόνο μόλις ο κώδικας τρέξει έναντι πραγματικών αρχείων αντί για μικρά fixtures. Η τεμαχισμένη διαδρομή CRC-32 τώρα διατίθεται ως μέρος της τυπικής διοχέτευσης εγγραφής στο εξάρτημα Excel HotXLS για Delphi και C++Builder, χωρίς τίποτα να ρυθμίσει ένας καλών και καμία ιδιότητα που το ενεργοποιεί ή το απενεργοποιεί