HotXLS có thể làm crash một luồng worker Delphi mà không có ngoại lệ nào bắt được khi nó tính checksum cho một phần XML worksheet lớn trong một lệnh gọi: zlib-ng chuyển sang thuật toán Chorba của nó trên khoảng 119 KB đầu vào, và biến thể generic-C của thuật toán đó cấp phát một mảng scratch đủ lớn để thổi bay stack luồng mặc định 1 MB. Delphi không bao giờ có cơ hội phản ứng, vì một tràn stack không phải loại ngoại lệ mà try/except được xây dựng để bắt
HotXLS là một thư viện Delphi và C++Builder gốc để đọc và ghi workbook Excel, và crash này lần ngược về bộ ghi worksheet của nó. Dấu hiệu đầu tiên của rắc rối là một ticket hỗ trợ: một job xuất qua đêm crash khoảng hai lần một tuần, luôn giữa chừng, không có hộp thoại ngoại lệ Delphi và không có lỗi được log, chỉ một tiến trình biến mất và một mục Windows Error Reporting không trỏ đến đâu hữu ích. Tái tạo lại tại bàn làm việc lại là chuyện hoàn toàn khác. Workbook nhỏ lưu tốt. Workbook lớn cũng lưu tốt, miễn là lượt lưu chạy trên luồng chính với một debugger đã gắn sẵn. Phải cần một lô file thực sự với kích thước sản xuất chạy qua đường xuất đa luồng thật mới đưa crash về nhà, và đến lúc đó I/O đĩa, áp lực bộ nhớ, và một template khả nghi đều đã bị loại trừ
Một lượt lưu worksheet biến thành một lệnh gọi CRC32 khổng lồ duy nhất như thế nào
File XLSX là các container ZIP, và định dạng ZIP yêu cầu một checksum CRC-32 cho mỗi mục, được ghi lại trong cả header file cục bộ lẫn central directory. HotXLS tính checksum đó bằng cách gọi một wrapper nhỏ tên ZLibCRC32, wrapper này lần lượt gọi routine crc32 riêng của zlib-ng một khi SaveAs đã dựng xong XML của một worksheet trong bộ nhớ, và trong một thời gian dài, lệnh gọi đó mang toàn bộ bộ đệm chưa nén trong một lời gọi duy nhất. Đó là một thiết kế hợp lý cho một worksheet nhỏ. Nó trở thành một lệnh gọi rất lớn ngay khi một sheet thuộc loại được nói đến trong hướng dẫn của chúng tôi về hiệu năng workbook lớn trong HotXLS, nơi XML của một sheet đơn thường xuyên vượt quá vài trăm kilobyte trước khi nó từng được nén
Vì sao zlib-ng cần một bộ đệm stack khổng lồ cho CRC32?
zlib-ng không dùng một triển khai CRC-32 duy nhất cho mọi lệnh gọi. Dưới một ngưỡng kích thước nó duyệt bộ đệm bằng tra cứu bảng và các thủ thuật gấp không cần bộ nhớ thêm đáng kể, và trên ngưỡng đó, khoảng 119 KB, chính xác là 118.960 byte trong bản build mà HotXLS liên kết tới, nó chuyển sang một thuật toán nhanh chuyên biệt gọi là Chorba. Triển khai generic-C của đường đó đánh đổi bộ nhớ lấy tốc độ: nó cấp phát một mảng scratch trên stack thay vì trên heap, có kích thước để làm cho vòng lặp trong của thuật toán nhanh, không phải để vừa vặn thoải mái bên trong bất cứ ngân sách stack nào mà luồng gọi tình cờ mang theo. Không điều nào trong số đó lộ ra từ phía caller. Một hàm checksum thông thường là một lệnh gọi lá, đọc vài byte, trả về một con số, không có cấp phát nào đáng bàn, và giả định đó đúng với đại đa số các lệnh gọi vào zlib-ng cho đến ngay khi một bộ đệm đủ lớn để vượt ngưỡng Chorba bước vào một lệnh gọi
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;
Vì sao các luồng worker thấy nó và việc debug tương tác thì không bao giờ
Kích hoạt crash này cần hai điều kiện cùng lúc: một phần XML worksheet đủ lớn để vượt ngưỡng Chorba của zlib-ng, và một luồng chỉ có stack mặc định thông thường thay vì thứ gì đó rộng rãi hơn. Các job xuất sản xuất chạm cả hai. Chúng chạy như các job hàng loạt phía server trải các lượt ghi HotXLS trên một pool luồng worker, mỗi luồng mang stack mặc định 1 MB mà Windows dự trữ trừ khi caller yêu cầu nhiều hơn, và mỗi luồng xử lý workbook khách hàng đủ lớn để có ý nghĩa. Việc debug tại bàn làm việc không đáng tin cậy chạm được điều kiện nào: các file mẫu thường nhỏ hơn ngưỡng, và các lượt chạy từng bước có xu hướng diễn ra trên luồng chính thay vì bên trong một worker vừa được sinh ra, nên hai điều kiện phải khớp nhau trong sản xuất gần như không bao giờ khớp nhau tại bàn làm việc của một lập trình viên
Truy tìm một crash đổ lỗi sai hàm
Các báo cáo crash mà đội có thể có được trỏ vào một vị trí bên trong hàm deflate của zlib-ng, không phải bất kỳ code HotXLS nào, và cũng không rõ ràng trỏ vào code CRC-32. Chi tiết đơn lẻ đó đưa lượt điều tra đầu tiên hướng về đường nén: kích thước bộ đệm truyền cho deflate, window bits, mức độ nén, tất cả các nghi phạm thông thường cho một crash gốc đến từ một codec. Không cái nào trong số đó đứng vững
Một frame trên cùng gây hiểu lầm
Một tràn stack là một loại crash kỳ lạ để gắn ký hiệu, vì đến lúc nó được báo cáo, con trỏ stack đã chạy vượt qua không gian dự trữ cho nó. Bất cứ thứ gì tạo ra báo cáo crash đó rất có thể đã giải quyết địa chỉ gây lỗi thành ký hiệu gần nhất nó còn tìm thấy được, và điểm vào đã export gần nhất nằm cạnh thủ phạm thật lại tình cờ là deflate. Lỗi thực sự nằm trong việc cấp phát bộ đệm scratch Chorba bên trong đường CRC-32, được biên dịch vào cùng thư viện, đủ gần trong file nhị phân để bị nhầm với hàm thực sự đang chạy
Chia đôi bằng dấu thời gian thay vì một debugger
Một crash làm sập toàn bộ tiến trình không để lại gì cho một phiên debugger Delphi thông thường bắt được, nên đội quay về dùng các checkpoint GetTickCount thả quanh mọi lệnh gọi khả nghi và một lượt chia đôi thủ công dọc đường lưu, thu hẹp dần thao tác nào đang diễn ra tại thời điểm tiến trình chết. Cùng với đó, một bản build cơ sở đã biết là tốt chạy cùng các file sản xuất song song với bản hiện tại, cụ thể để loại trừ một hồi quy trong chính các thay đổi của đợt đó trước khi nhìn xa hơn về thượng nguồn. Chỉ sau khi cả hai kiểm tra đều sạch, cuộc điều tra mới đổ về một phụ thuộc bên thứ ba đang làm điều gì đó bất ngờ với một đầu vào hoàn toàn hợp lệ
Vì sao try/except không bắt được một tràn stack?
Một tràn stack không phải một ngoại lệ mà code Delphi từng cố ý ném ra, và nó cũng không được chuyển giao theo cách Windows chuyển giao một access violation hay một phép chia cho không. Nó lộ ra như một lỗi guard-page phần cứng, được báo cáo qua chính cơ chế xử lý ngoại lệ có cấu trúc mà try/except của Delphi được xây dựng trên đó, nhưng đúng thời điểm nó kích hoạt thường không còn không gian stack nào để chạy một handler, unwind code dọn dẹp, hay thậm chí hoàn tất việc báo cáo lỗi một cách sạch sẽ. Trên một luồng worker chỉ mang dự trữ mặc định 1 MB, với một bộ đệm scratch kích thước đó đã tiêu thụ phần lớn những gì còn lại, không còn gì để runtime làm việc
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;
Khối except đó trông như một tấm lưới an toàn, và với hầu hết các lỗi nó đúng là vậy, nhưng nó không làm gì được ở đây. Đội đã xác nhận điều đó trong thực tế: try/except không bắt được gì, khối finally cũng không bao giờ có cơ hội đáng tin cậy để chạy, và người vận hành thấy một tiến trình đã chết mà không có mục log cấp ứng dụng nào cả, đúng như ticket hỗ trợ ban đầu mô tả
Cách khắc phục: đưa dữ liệu vào CRC32 theo lát 64 KB thay vì một lệnh gọi khổng lồ
Bản sửa mà HotXLS phát hành không thay đổi gì về bản thân zlib-ng và không thay đổi gì về mức độ nén dùng để ghi workbook. ZLibCRC32 giờ duyệt qua đầu vào theo các lát cố định 64 KB, 65536 byte mỗi lát, gọi crc32 của zlib-ng một lần cho mỗi lát và luồn giá trị checksum đang chạy từ lệnh gọi này sang lệnh gọi tiếp theo. CRC-32 là một thuật toán tăng dần về cấu trúc, nên một checksum được xây dựng qua nhiều lát giống hệt từng bit với một checksum được tính trong một lệnh gọi duy nhất trên cùng các byte: bản sửa thay đổi cách công việc được chia, không thay đổi những gì nó tính
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;
Không có gì về lệnh gọi SaveAs xung quanh cần thay đổi để điều này hoạt động, và không có gì về các mục ZIP mà HotXLS ghi thay đổi: giá trị CRC-32 cuối cùng nằm trong header file cục bộ và central directory chính xác là giá trị mà một lệnh gọi khổng lồ duy nhất sẽ tạo ra, chỉ được lắp ráp từ các mảnh nhỏ hơn. Việc hạ cấp zlib-ng hay rơi về một triển khai CRC-32 chậm hơn, nhẹ cấp phát hơn cũng sẽ tránh được crash, nhưng với một cái giá thực sự cho mọi file chưa bao giờ đến gần ngưỡng đó ngay từ đầu, đó là lý do vì sao không phương án nào trong hai phương án đó được phát hành
Điều này có ý nghĩa gì nếu bạn gọi zlib-ng từ các luồng worker của riêng bạn
Chế độ lỗi tràn stack được mô tả ở đây không liên quan gì đặc thù đến bảng tính. Bất kỳ ứng dụng nào đưa cho zlib-ng một bộ đệm lớn, dù cho nén, giải nén, hay một checksum, từ một luồng chỉ mang stack mặc định của nền tảng đều có thể va phải cùng loại bức tường, vì thư viện chọn thuật toán của nó theo kích thước đầu vào và một số thuật toán đó giả định có stack dư dả. Hai biện pháp phòng thủ hoạt động mà không đụng đến chính zlib-ng: đưa các bộ đệm lớn vào các routine nhạy cảm với kích thước theo các khối cố định loại bỏ hoàn toàn điều kiện kích hoạt cho bất kỳ thuật toán nào vốn tăng dần một cách tự nhiên, và ở nơi việc chia khối không phải một lựa chọn, cho luồng gọi một stack lớn hơn mặc định của nền tảng là đòn bẩy khác. Cả hai đều rẻ hơn việc phát hiện ra một ngưỡng kích thước không có tài liệu từ một báo cáo crash sản xuất đổ lỗi sai hàm
Ngưỡng cụ thể này vẫn vô hình cho đến khi một workbook sản xuất đủ lớn vượt qua nó trên đúng loại luồng sai, chính xác là loại lỗi chỉ lộ ra một khi code chạy trên các file thật thay vì các fixture nhỏ. Đường CRC-32 chia khối giờ đi kèm sẵn như một phần của pipeline ghi tiêu chuẩn trong HotXLS Excel Component dành cho Delphi và C++Builder, không có gì để caller cấu hình và không có thuộc tính nào bật hay tắt nó