يمكن لـHotXLS تعطيل خيط عامل في Delphi دون استثناء قابل للالتقاط عند حساب مجموع اختباري لجزء XML كبير لورقة عمل في استدعاء واحد: تنتقل zlib-ng إلى خوارزمية Chorba الخاصة بها فوق نحو 119 كيلوبايت من المدخلات، وتُخصص نسخة C العامة من تلك الخوارزمية مصفوفة عمل مؤقتة كبيرة بما يكفي لتفجير مكدس الخيط الافتراضي البالغ 1 ميغابايت. لا تحصل Delphi أبدًا على فرصة للتفاعل، لأن تجاوز المكدس ليس نوع الاستثناء الذي بُني try/except لالتقاطه
HotXLS مكتبة أصلية لـDelphi وC++Builder لقراءة وكتابة مصنّفات عمل Excel، وتتبّع التعطل عائدًا إلى كاتب ورقة العمل الخاص بها. كانت العلامة الأولى على المشكلة تذكرة دعم: مهمة تصدير ليلية تتعطل نحو مرتين أسبوعيًا، دائمًا في منتصف التشغيل، دون مربع حوار استثناء Delphi ودون خطأ مسجَّل، مجرد عملية تختفي وإدخال تقرير أخطاء ويندوز لا يشير إلى أي شيء مفيد. كانت إعادة إنتاج المشكلة على مكتب أمرًا مختلفًا تمامًا. حُفظت مصنّفات العمل الصغيرة بنجاح. حُفظت مصنّفات العمل الكبيرة بنجاح أيضًا، طالما جرى الحفظ على الخيط الرئيسي مع مُصحح أخطاء مرفَق بالفعل. استغرق الأمر دفعة فعلية من ملفات بحجم إنتاج تعمل عبر مسار التصدير الفعلي متعدد الخيوط لجلب التعطل إلى المنزل، وبحلول تلك النقطة كان قد استُبعد بالفعل كل من إدخال/إخراج القرص، وضغط الذاكرة، وقالب مشتبه به
كيف يتحول حفظ ورقة عمل إلى استدعاء CRC32 عملاق واحد
ملفات XLSX حاويات ZIP، وتتطلب صيغة ZIP مجموعًا اختباريًا CRC-32 لكل إدخال، مسجَّلًا في كل من ترويسة الملف المحلية والدليل المركزي. يحسب HotXLS ذلك المجموع الاختباري باستدعاء غلاف صغير باسم ZLibCRC32، الذي يستدعي بدوره روتين crc32 الخاص بـzlib-ng نفسه بمجرد أن تنتهي SaveAs من تجميع XML الخاص بورقة عمل في الذاكرة، ولفترة طويلة كان ذلك الاستدعاء يحمل المخزن المؤقت غير المضغوط بأكمله في استدعاء واحد. هذا تصميم معقول لورقة عمل صغيرة. يصبح استدعاءً كبيرًا جدًا بمجرد أن تكون الورقة من النوع المشروح في دليلنا حول أداء مصنّفات العمل الكبيرة في HotXLS، حيث يتجاوز XML لورقة واحدة بضع مئات من الكيلوبايتات روتينيًا قبل أن يُضغط أصلًا
لماذا تحتاج zlib-ng مخزنًا مؤقتًا عملاقًا للمكدس لـCRC32؟
لا تستخدم zlib-ng تنفيذًا واحدًا لـCRC-32 لكل استدعاء. تحت عتبة حجم معينة تجتاز المخزن المؤقت بعمليات بحث في جداول وحيل طيّ لا تحتاج ذاكرة إضافية ذات معنى، وفوق تلك العتبة، نحو 119 كيلوبايت، بالضبط 118,960 بايت في البنية التي يرتبط بها HotXLS، تنتقل إلى خوارزمية سريعة متخصصة تسمّى Chorba. يقايض التنفيذ العام بلغة C لذلك المسار الذاكرة بالسرعة: يخصص مصفوفة عمل مؤقتة على المكدس بدلًا من الكومة (heap)، بحجم يجعل الحلقة الداخلية للخوارزمية سريعة، لا لتلائم بارتياح داخل أيًّا كانت ميزانية المكدس التي يصادف أن يحملها الخيط المستدعي. لا شيء من ذلك مرئي من جانب المستدعي. دالة مجموع اختباري عادة استدعاء ورقي (leaf call)، اقرأ بعض البايتات، أعد رقمًا، لا تخصيص يستحق النقاش، وذلك الافتراض يصمد للأغلبية الساحقة من الاستدعاءات إلى zlib-ng حتى يدخل مخزن مؤقت كبير بما يكفي لعبور عتبة 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 ميغابايت الافتراضي الذي يحجزه ويندوز ما لم يطلب المستدعي أكثر، ويعالج كل منها مصنّفات عمل عملاء كبيرة بما يكفي لتهم. لم يصطدم تصحيح الأخطاء على المكتب بأي من الشرطين بشكل موثوق: كانت ملفات العينة عادة أصغر من العتبة، وميلت عمليات التشغيل خطوة بخطوة إلى الحدوث على الخيط الرئيسي بدلًا من داخل خيط عامل مُولَّد حديثًا، بحيث لم يصطف الشرطان اللذان كان يجب أن يصطفا في الإنتاج أبدًا تقريبًا على مكتب مطوّر
تعقّب تعطل ألقى اللوم على الدالة الخاطئة
أشارت تقارير التعطل التي استطاع الفريق الحصول عليها إلى موضع داخل دالة deflate الخاصة بـzlib-ng، لا إلى أي شيفرة HotXLS، ولا بشكل واضح إلى شيفرة CRC-32 أيضًا. أرسل ذلك التفصيل الواحد المرور الأول من التحقيق نحو مسار الضغط: أحجام المخازن المؤقتة الممرَّرة إلى deflate، وبتات النافذة، ومستوى الضغط، كل المشتبه بهم المعتادين لتعطل أصلي قادم من مرمّز. لم يصمد أي منها
إطار علوي مضلِّل
تجاوز المكدس نوع غريب من التعطل لترميزه، لأنه بحلول وقت الإبلاغ عنه، يكون مؤشر المكدس قد تجاوز بالفعل المساحة التي حُجزت له. أيًّا كان ما أنتج تقرير التعطل ذاك على الأرجح حلّ العنوان المُخطئ إلى أقرب رمز لا يزال قادرًا على إيجاده، وصادف أن تكون أقرب نقطة دخول مُصدَّرة تجلس بجانب الجاني الحقيقي هي deflate. الخلل الفعلي جلس في تخصيص المخزن المؤقت المؤقت لـChorba داخل مسار CRC-32، مُترجَمًا في المكتبة نفسها، قريبًا بما يكفي في الملف الثنائي ليُخطأ بالدالة التي كانت تعمل فعليًا
التقسيم الثنائي بالطوابع الزمنية بدلًا من مُصحح أخطاء
تعطل يُسقط العملية بأكملها لا يترك شيئًا لجلسة مُصحح أخطاء Delphi عادية لالتقاطه، لذا عاد الفريق إلى نقاط تفتيش GetTickCount موضوعة حول كل استدعاء مشتبه به وتقسيم ثنائي يدوي عبر مسار الحفظ، مضيّقًا أي عملية كانت قيد التنفيذ في اللحظة التي ماتت فيها العملية. جنبًا إلى جنب مع ذلك، شغّل بناء أساسي معروف الجودة الملفات الإنتاجية نفسها جنبًا إلى جنب مع البناء الحالي، تحديدًا لاستبعاد انحدار في تغييرات تلك الجولة نفسها قبل النظر إلى أبعد من ذلك في المنبع. لم يستقر التحقيق على اعتماد طرف ثالث يفعل شيئًا غير متوقع بمدخل صالح تمامًا إلا بعد أن عاد كلا الفحصين نظيفين
لماذا يفشل try/except في التقاط تجاوز المكدس؟
تجاوز المكدس ليس استثناءً تُطلقه شيفرة Delphi عمدًا أبدًا، وليس مُسلَّمًا بالطريقة التي يُسلِّم بها ويندوز خرق وصول أو قسمة على صفر أيضًا. يظهر كخلل صفحة حماية عتادي، مُبلَّغ عنه عبر آلية معالجة الاستثناءات المُهيكلة نفسها التي بُني عليها try/except في Delphi، لكن في اللحظة الدقيقة لإطلاقه لا توجد عادة مساحة مكدس متبقية لتشغيل معالج، أو تفكيك شيفرة تنظيف، أو حتى إنهاء الإبلاغ عن الخلل بنظافة. على خيط عامل يحمل فقط الحجز الافتراضي البالغ 1 ميغابايت، مع مخزن مؤقت مؤقت بذلك الحجم استهلك بالفعل معظم ما تبقى، لا يبقى شيء ليعمل به وقت التشغيل
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 كيلوبايت بدلًا من استدعاء عملاق واحد
الحل الذي شحنه HotXLS لا يغيّر شيئًا في zlib-ng نفسها ولا شيئًا في مستوى الضغط المستخدم لكتابة مصنّف العمل. تجتاز ZLibCRC32 الآن المدخل بشرائح ثابتة من 64 كيلوبايت، 65536 بايت لكل منها، مستدعية crc32 الخاصة بـzlib-ng مرة لكل شريحة ومُمرِّرة قيمة المجموع الاختباري الجارية من استدعاء إلى التالي. CRC-32 خوارزمية تراكمية بالتصميم، بحيث يكون مجموع اختباري مبني عبر عدة شرائح مطابقًا بت لبت لمجموع محسوب في استدعاء واحد عبر البايتات نفسها: يغيّر الحل كيفية تقسيم العمل، لا ما يحسبه
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 مخزنًا مؤقتًا كبيرًا، سواء للضغط، أو فك الضغط، أو مجموع اختباري، من خيط لا يحمل سوى مكدس المنصة الافتراضي يمكن أن يصطدم بالجدار نفسه، لأن المكتبة تختار خوارزميتها بحسب حجم المدخل وتفترض بعض تلك الخوارزميات وجود مكدس فائض. دفاعان يعملان دون لمس zlib-ng نفسها: تغذية مخازن مؤقتة كبيرة إلى روتينات حساسة للحجم بقطع ثابتة يزيل شرط الإطلاق كليًا لأي خوارزمية تراكمية بطبيعتها، وحيث لا يكون التقسيم خيارًا، فإن منح الخيط المستدعي مكدسًا أكبر من افتراضي المنصة هو الرافعة الأخرى. أي منهما أرخص من اكتشاف عتبة حجم غير موثَّقة من تقرير تعطل إنتاجي يلوم الدالة الخاطئة
بقيت هذه العتبة تحديدًا غير مرئية حتى عبرها مصنّف عمل إنتاجي كبير بما يكفي على النوع الخاطئ من الخيوط، وهذا بالضبط نوع الفشل الذي لا يظهر إلا بمجرد أن تعمل الشيفرة مقابل ملفات حقيقية بدلًا من تجهيزات صغيرة. يُشحن مسار CRC-32 المُقسَّم الآن كجزء من خط أنابيب الكتابة القياسي في مكوّن HotXLS لـExcel لـDelphi وC++Builder، دون أي شيء على المستدعي إعداده ودون خاصية تُفعّله أو تُلغيه