مرکزی مواد پر جائیں
OpenAI

۳۰ جون، ۲۰۲۶

انجینئرنگ

کور ڈمپ وبائیات: 18 سال پرانا بگ ٹھیک کرنا

ہمارے data infrastructure کے پیچیدہ crashes debug کرنے کے لیے population-level analysis کا استعمال.

لوڈ ہو رہا ہے…

OpenAI کے ماڈلز اور ایجنٹس inference کے وقت، یعنی جب ماڈلز آپ کے سوال پر غور کر رہے ہوتے ہیں، متعلقہ ڈیٹا تلاش کرنے کے لیے scalable data infrastructure پر تیزی سے انحصار کر رہے ہیں. ان میں سے کچھ سروسز C++ میں لکھی گئی ہیں، جس کا system پر low-level control ہمیں performance زیادہ سے زیادہ اور memory usage کم سے کم رکھنے دیتا ہے. scale کرتے وقت یہ efficiency فوائد اہم ہیں، مگر C++ میں memory safety نہ ہونے کا مطلب ہے کہ bugs غلط یا غیر موجود memory addresses پر لکھ کر crashes کرا سکتے ہیں.

چند ماہ پہلے ہم نے Rockset service کے اندر کچھ crashes دیکھے، جو ہمارے ChatGPT data infrastructure کا ایک bespoke حصہ ہے اور کئی data plugins اور conversations میں search کے لیے کلیدی ہے. ان ہر crash میں ایک عام C++ function ختم ہو کر کسی جعلی address پر return کرتا دکھا، جس سے kernel نے program روک دیا کیونکہ instruction pointer اب code کی طرف اشارہ نہیں کر رہا تھا. کبھی stack frame میں return address slot NULL ہوتا تھا. کبھی stack pointer CPU register خود 8 bytes سے ہٹا ہوا لگتا تھا، جیسے %rsp کسی طرح normal execution کے بیچ decrement ہو گیا ہو. دونوں صورتوں میں crash return پر ہوا.

یہ application code کے عام failure modes نہیں ہیں. ایک stray write کا صرف saved return address پر جا لگنا ممکن ہے، مگر انتہائی غیر محتمل. ایسا bug جو inline assembly، setcontext یا longjmp کے بغیر، جنہیں ہم استعمال نہیں کرتے، %rsp کو 8 سے misalign کر دے، اور بھی عجیب ہے، کیونکہ compiled code اس register کو براہ راست صرف function prologue اور epilogue میں adjust کرتا ہے. ہر hypothesis جو ہم یا ChatGPT سوچ سکے، اس کے خلاف مضبوط evidence تھا، اس لیے bug ناممکن سا لگتا تھا.

جسے ہم ایک مسئلہ سمجھ رہے تھے، وہ آخرکار دو غیر متعلقہ bugs نکلے، جو اتفاقاً ایک ہی وقت میں ملے. پہلا، ایک Azure host پر silent hardware corruption، جہاں CPU حساب ہی درست نہیں کر رہا تھا. دوسرا، GNU libunwind میں 18 سال پرانا race condition، ایک widely used open source library کا unnoticed bug.

یہ post اس کہانی کے بارے میں ہے کہ ہم نے epidemiologist کی طرح سوچ کر اور crashes کی پوری population کا high-quality data set بنا کر بظاہر ناقابل توضیح crashes کو کیسے شناخت اور fix کیا.

debugging کی پہلی کوشش: چند core dumps کا باریک بینی سے جائزہ

پہلے Rockset کو ذرا گہرائی سے دیکھتے ہیں. یہ search اور real-time analytics کے لیے cloud-native data system ہے، جسے ہم OpenAI میں کئی internal use cases، جیسے sync connectors، کے لیے استعمال کرتے ہیں. Rockset کو 2024 میں OpenAI نے acquire کیا تھا. streaming updates ایک ورک اسپیس کی knowledge base کا up-to-date index برقرار رکھنے کے لیے استعمال ہوتی ہیں، تاکہ ChatGPT سوالات کے جواب دیتے یا actions کرتے وقت relevant information search کر سکے.

Rockset کی execution layer C++ میں لکھی گئی ہے. C++ زبان CPU تک low-level access دیتی ہے، جو performance اور efficiency کے لیے اچھا ہے، مگر اس کا مطلب ہے کہ application bugs invalid memory accesses اور segfaults کا سبب بن سکتے ہیں. انہیں track کرنے کے لیے ہم folly کا fatal signal handler استعمال کرتے ہیں تاکہ crash کے وقت stack trace log ہو، اور متعلقہ core dumps، یعنی crash کے وقت program state کا snapshot، بعد کے analysis کے لیے Azure blob storage پر upload کرتے ہیں. Rockset کے تمام query processing leaves replicated ہیں، جس سے crash کا client impact کم سے کم رہتا ہے. تاہم ہر segfault ایک ایسے bug کی نشانی ہے جسے ہمارے reliability اور quality goals کے لیے fix کرنا ضروری ہے.

ہمارا ابتدائی طریقہ یہ تھا کہ ان cores کو روایتی debugging مسئلہ سمجھا جائے: چند core dumps کو بہت قریب سے دیکھیں، hypotheses بنائیں، اور انہیں ایک ایک کر کے رد کریں.

زیادہ تر crashes DocumentTree::updateDocument نامی method میں ہوئے. ان crashes میں یوں لگتا تھا کہ updateDocument نے کوئی نامعلوم function X call کیا، X کے active رہتے stack corrupt ہوا، پھر X ایسے address پر return ہوا جو executable code نہیں تھا. کچھ cases میں X کا just-popped frame valid لگتا تھا، سوائے اس کے کہ اس کا saved return address NULL تھا. دوسرے cases میں stack pointer خود غلط لگتا تھا، مگر اگلا valid frame پھر بھی updateDocument ہی دکھتا تھا.

ہم نہیں جانتے تھے کہ stack کب corrupt ہو رہا ہے، اس لیے search space بہت بڑا تھا. updateDocument ایک بڑا method ہے جس میں بہت inlining ہوتی ہے، اس لیے X کے candidates بے حد زیادہ تھے.

کیا یہ ہمارے C++ code کا bug تھا؟ compiler یا linkage کا issue؟ ہماری runtime libraries میں سے کسی ایک کا مسئلہ؟ signal delivery یا context switching کے آس پاس Linux kernel bug؟ یا اس سے بھی نایاب کچھ؟ اگر یہ stray write تھا تو ہمارے ASAN staging environment نے اسے پکڑا کیوں نہیں؟

ہم نے اپنے application-level logs سے مسئلے کے تمام occurrences شناخت کرنے کی کوشش کی، مگر stack-corruption bugs کو صرف logs سے classify کرنا مشکل ہے کیونکہ logged stack traces خود corrupt یا missing ہوتے ہیں. ہم ایسی log query نہیں بنا سکے جس میں false positives اور false negatives دونوں نہ ہوں. ہم نے مزید cores manually inspect کیے اور کچھ اضافی examples ملے، مگر یہ عمل اتنا محنت طلب تھا کہ قابل اعتماد data set نہ دے سکا.

تحقیق کے اس مرحلے پر ہم نے hardware bug کو غلط طور پر رد کر دیا، کیونکہ crashes کئی regions اور hardware types میں دکھے؛ اس لیے ہم اب بھی صرف software causes ڈھونڈ رہے تھے. کچھ دن ہم ایک misaligned-%rsp crash پر بہت گہرائی میں گئے، stack اور register contents سے pre-crash history reconstruct کرتے رہے. اس سے کچھ ممکنہ clues ملے، مگر چونکہ ہم نے اپنی ابتدائی conclusion نہیں چھوڑی کہ تمام bugs کی وجہ ایک ہی ہے، اس سے بات آگے نہیں بڑھی.

stack سے ملنے والے اشارے

تحقیق کے turning point تک پہنچنے سے پہلے یہ سمجھانا ضروری ہے کہ ہم core files سے کس طرح کی information نکال رہے تھے.

Rockset کو -fno-omit-frame-pointer کے ساتھ compile کیا جاتا ہے، اس لیے active stack frame ہمیشہ %rbp کے ذریعے reachable ہوتا ہے، اور callers frame pointers کی linked list بناتے ہیں.

Linux x86_64 پر AMD64 System V ABI بھی %rsp کے نیچے 128 bytes کو red zone کے طور پر reserve کرتا ہے. یہ region userspace code کے لیے available ہے اور، اہم بات یہ ہے کہ ABI contract کے تحت kernel وعدہ کرتا ہے کہ signal deliver کرتے وقت اسے clobber نہیں کرے گا.

red zone post-return crash کی debugging میں مرکزی تھا، کیونکہ یہ return سے پہلے کی کچھ information محفوظ رکھتا ہے. جب SIGSEGV trigger ہوتا ہے، folly کا fatal signal handler crashing thread کے stack پر چلتا ہے. جو stack frames اب active نہیں رہتے، کیونکہ ان کا function return ہو چکا ہوتا ہے، وہ signal handler سے clobber ہو جائیں گے، سوائے آخری 128 bytes کے. اسی لیے ہم کہہ سکتے ہیں، “X کا just-popped stack frame valid لگتا تھا، سوائے NULL return address کے.” red zone inactive frames کا کچھ حصہ، یا کبھی صرف کسی inactive frame کا tail، محفوظ رکھتا ہے.

Stack diagram جس میں corrupted stack frames دکھائے گئے ہیں جو return addresses overwrite کر کے crashes کا سبب بن سکتے ہیں.

ہمیں ایک misaligned-stack crash ملا جس میں شامل تمام functions بہت چھوٹے تھے. اس سے ہم دیکھ سکے کہ %rsp ایک نسبتاً simple function کی execution کے دوران misalign ہوا تھا، اور اس کے بعد مزید calls کامیاب ہوئی تھیں. program تبھی crash ہوا جب active function نے آخرکار return کرنے کی کوشش کی. ان code paths میں exceptions، inline assembly، setcontext یا longjmp استعمال نہیں ہوئے؛ اس لیے اگر stack pointer واقعی core کے اشارے کے مطابق بدلا تھا، تو userspace code کا کوئی plausible bug مسئلے کو explain نہیں کرتا تھا.

اس نے ہمیں kernel کی طرف دھکیلا.

Rockset زیادہ تر programs کے مقابلے signals کو زیادہ aggressive طریقے سے استعمال کرتا ہے. Query execution کو بہت سے lightweight tasks میں توڑا جاتا ہے جو data exchange کرتے ہیں. یہ high-QPS workloads کو efficiently handle کرنے کے لیے اہم ہے، مگر per-query CPU accounting کو awkward بنا دیتا ہے، کیونکہ کئی queries کا work ایک ہی thread pool پر multiplex ہوتا ہے.

ہمارا solution وہ ہے جسے ہم coarse_thread_cputime_clock کہتے ہیں، جو clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) کو اتنا سستا approximate کرتا ہے کہ ہر task boundary پر sample کیا جا سکے. timer_create API کو time کے گزرنے کی کئی notions، بشمول CPU time کے accumulation، کی بنیاد پر periodic signal delivery schedule کرنے کے لیے استعمال کیا جا سکتا ہے. ہم CPU time کے ہر چند milliseconds بعد ایک signal (SIGUSR2) deliver کرنے کے لیے schedule کرتے ہیں، جس پر signal handler thread-local value update کرتا ہے. اگرچہ بہت سے tasks اپنی execution کے دوران coarse clock کو آگے بڑھتا نہیں دیکھتے، تمام deltas کا sum query کے actual CPU time کا unbiased estimate دیتا ہے.

چونکہ ہم signals بہت کثرت سے deliver کرتے ہیں، context switching یا signal delivery کے آس پاس کوئی rare kernel bug plausible لگتا تھا. ہم نے bug reports، kernel source code اور Azure-specific kernel patches پڑھنے میں وقت لگایا. ہم نے stress tests آزمائے. ہمیں کوئی متعلقہ چیز نہیں مل سکی.

اس وقت ہم نے پیچھے ہٹ کر ایک مختلف approach آزمانے کا فیصلہ کیا.

ڈاکٹر یا epidemiologist؟

ایسے مسئلے کو debug کرنے کے دو broad طریقے ہیں.

ایک یہ کہ کسی حد تک ڈاکٹر کی طرح عمل کریں: ایک patient پر focus کریں، بہت سے tests چلائیں، اور detailed evidence سے ایک case diagnose کرنے کی کوشش کریں.

دوسرا یہ کہ epidemiologist کی طرح پوری population کو دیکھیں اور پوچھیں کہ کیا ایسے patterns ہیں جو ایک single case نہیں دکھا سکتا. کیا bug کسی specific release پر شروع ہوا؟ کیا اس کا تعلق ایک hardware SKU، یعنی specific CPU اور server model، ایک region، یا ایک kernel version سے ہے؟ کیا ایک syndrome جیسے دکھنے والی چیز کے اندر multiple distinct clusters چھپے ہیں؟

ہم زیادہ تر doctor mode میں تھے. کلیدی تبدیلی یہ فیصلہ تھا کہ ہمیں high-quality population data جمع کرنا ہے.

data کی صفائی

مسئلے کی تمام instances خودکار طور پر ڈھونڈنے کی ہماری پچھلی کوششیں ناکام ہوئیں، کیونکہ ہم logs پر text searches استعمال کر رہے تھے. core dumps خود کہیں زیادہ information رکھتے ہیں، مگر انہیں manually دیکھنا scale نہیں ہوتا تھا. ہم نے ایک pipeline بنانے کی محنت کرنے کا فیصلہ کیا جو core dumps کو خودکار طور پر analyze کر سکے.

ہم نے ChatGPT سے script لکھوایا جو ہر core file کا prefix download کرتا، registers extract کرتا، logs سے known false positives filter کرتا، اور crash کو return-to-null، misaligned-stack یا other کے طور پر automatically label کرتا. پھر ہم نے اس script کو پچھلے سال کے ہر production Rockset core dump پر parallel چلایا.

یہ turning point تھا.

clean data set ملتے ہی correlations فوراً سامنے آ گئیں. جسے ہم ایک عجیب bug سمجھ رہے تھے، وہ دراصل دو الگ crash populations تھیں.

return-to-null cores کئی clusters اور geographic regions میں پھیلے ہوئے تھے. ان کی frequency حال ہی میں بڑھی تھی، مگر کوئی صاف start date یا واضح infrastructure boundary نہیں تھی.

misaligned-stack crashes بالکل مختلف دکھے. وہ سب ایک region سے آئے، ان کی clear start date تھی، اور وہ ان نوڈز پر کبھی نہیں ہوئے جو کافی عرصے سے running تھے. اگرچہ ان میں multiple Azure VMs، یعنی cloud میں hosted virtual machines، شامل تھیں، pattern ایسا لگا جیسے خراب hardware والی ایک physical machine جس VM پر بھی آئے اسے problem دے رہی ہو.

وقت کے ساتھ cluster کے لحاظ سے crash rates کا dot plot، جس میں زیادہ تر crashes clusters 2، 3، اور 6 میں مرکوز ہیں، اور period کے آخر کے قریب cluster 1 میں spike دکھتا ہے.

اسی لمحے ہمیں احساس ہوا کہ ہم ذہنی طور پر دو bugs کو ملا رہے تھے. چونکہ ہم دونوں bugs کے counterexamples ملا رہے تھے، ہم ایک coherent explanation نہیں ڈھونڈ پا رہے تھے.

Bug #1: bad host

Kubernetes نوڈز اور timestamps کی clean list کے ساتھ، ہم misaligned-stack crashes کو ایک single physical host تک trace کر سکے، جسے denylist کرنا آسان تھا.

کئی ہفتوں کی stress testing کے بعد بھی ہم controlled environment میں اس host پر register corruption reproduce نہیں کر سکے. تاہم problematic host کو service سے نکالتے ہی misaligned-stack crashes غائب ہو گئے.

bad host کو ہٹانا permanent solution نہیں ہے، اس معنی میں کہ یہ اسی مسئلے کے نئے occurrence کو نہیں روکتا. البتہ ہم software بدل سکتے ہیں تاکہ اگر ایسا issue دوبارہ آئے تو اسے آسانی سے detect اور handle کیا جا سکے. ہم نے اپنے fatal signal handler کو بہتر کیا تاکہ وہ register state بھی include کرے، جس سے ہم recurrence کو صرف logs سے detect کر سکیں، core dump کی ضرورت نہ ہو. ہم نے control plane بدلا تاکہ VMs عموماً recycle کے بجائے reuse ہوں، جس سے infrastructure stack کے ہمارے level پر bad-node detection بہت آسان ہو جاتی ہے. ہم نے اپنے runbooks، اور اپنی team کے mental models، کو بھی update کیا تاکہ یہ possibility شامل ہو.

bad-host crashes الگ کرنے کے بعد باقی return-to-null cores کو سمجھنا بہت آسان ہو گیا. پہلے ہم نے exception unwinding کو رد کیا تھا، کیونکہ ہمیں لگا کہ ہمارے پاس counterexamples ہیں: ایسے code paths میں crashes جہاں exceptions یقینی طور پر استعمال نہیں ہوئیں. مگر وہ counterexamples سب hardware-corruption cluster سے تھے.

جب ہم نے یہ بات ذہن میں رکھ کر باقی cores دوبارہ دیکھے تو معلوم ہوا کہ یہ conclusion بالکل الٹی تھی: crashes سب exception unwinding کے دوران ہو رہے تھے.

Exception handling ایک dynamic control transfer ہے

جب C++ کوئی exception throw کرتا ہے، runtime کو معلوم کرنا ہوتا ہے کہ کون سا catch block اسے receive کرے اور راستے میں کون سے destructors یا cleanup handlers چلیں. compiler یہ metadata emit کرتا ہے، مگر actual matching runtime پر dynamically ہوتی ہے.

Exception unwinding دراصل throw invoke کرنے والے function سے نہیں، بلکہ resulting compiled code کی called helper functions سے performed ہوتی ہے. یہ runtime routines stack examine کرتی ہیں، stack پر ملنے والے functions کا metadata fetch کرتی ہیں، cleanup handlers اور catch blocks dynamically تلاش کرتی ہیں، پھر control کو ان locations میں سے کسی ایک پر transfer کرتی ہیں. control transfer کرنے میں intervening stack frames، بشمول helper functions کے frames، سب کو unwind کرنا شامل ہے.

Operationally، یہ normal call and return کے مقابلے longjmp یا fiber switch سے کہیں زیادہ ملتا ہے. callee save registers کے ساتھ stack frame registers %rbp اور %rsp بھی restore ہونے چاہئیں.

ہمارا binary دو libraries سے link ہوتا ہے جن میں C++ exception unwinding کرنے والی functions کی implementations ہیں: libgcc اور GNU libunwind. dynamic linker نے GNU libunwind کی definitions منتخب کی تھیں. یہ ہمارے لیے حیران کن تھا؛ ہم symbol versioning rules کی وجہ سے libgcc implementation کے win ہونے کی توقع کر رہے تھے؛ مگر running binaries inspect کرنے سے پتا چلا کہ ایسا نہیں تھا.

ایک آخری assumption واپس لینا

اس point پر ہماری working hypothesis بدل گئی، کیونکہ ہم نے ایک اور assumption نرم کی جو ہم نے اس وقت بنائی تھی جب ہمیں لگا تھا کہ صرف ایک bug ہے.

شاید ہم ایک ordinary function کو NULL پر return ہوتے نہیں دیکھ رہے تھے. شاید ہم unwind transfer دیکھ رہے تھے، یعنی effectively setcontext-style register restore، جہاں destination instruction pointer control transfer ہونے سے پہلے NULL ہو گیا تھا. دوسرے لفظوں میں، stack پر incorrect return address slot کے بجائے unwind library سے incorrect data.

اس سے problem dramatic طور پر narrow ہو گیا. یا تو GNU libunwind غلط destination state compute کر رہا تھا، یا صحیح state compute کر رہا تھا مگر apply ہونے سے پہلے کوئی چیز اسے corrupt کر رہی تھی.

ہم نے GNU libunwind source پڑھا اور پایا کہ یہ stack پر ایک ucontext_t synthesize کرتا ہے، cleanup handler کے frame کے لیے desired register state fill کرتا ہے، پھر اس struct کا pointer ایک internal assembly routine کو دیتا ہے: _Ux86_64_setcontext.

اس point پر ہمارے پاس تمام pieces تھے.

synthesized ucontext_t ان stack frames میں سے ایک میں رہتا ہے جسے _Ux86_64_setcontext اسی function کی execution کے دوران unwind کرتا ہے. کیا _Ux86_64_setcontext struct سے اس وقت read کر رہا تھا جب اس نے %rsp بدل دیا، یعنی جب struct active stack کا حصہ نہیں رہا تھا؟ اس سے وہ signal delivery، جیسے ہمارے frequent SIGUSR2، کے ذریعے clobber ہونے کے لیے vulnerable ہو جاتا.

Bug #2: libunwind bug

جواب ہاں تھا.

GNU libunwind کے ہمارے استعمال والے version میں _Ux86_64_setcontext کی آخری چھ instructions یہ ہیں، جو زیادہ تر mov instructions پر مشتمل ہیں جو memory سے destination register میں load کرتی ہیں:

سادہ متن

1
74: mov UC_MCONTEXT_GREGS_RSP(%rdi),%rsp
2
75:
3
76: /* push the return address on the stack */
4
77: mov UC_MCONTEXT_GREGS_RIP(%rdi),%rcx
5
78: push %rcx
6
79:
7
80: mov UC_MCONTEXT_GREGS_RCX(%rdi),%rcx
8
81: mov UC_MCONTEXT_GREGS_RDI(%rdi),%rdi
9
82: retq

(%rdi stack-allocated ucontext_t کی طرف point کرتا ہے، اور UC_MCONTEXT_* macros صرف اس fixed offset میں expand ہوتے ہیں جہاں کوئی particular register stored ہوتا ہے.)

پہلی instruction race window کا آغاز ہے. یہ %rsp کو update کر کے active stack کے نئے bottom کی طرف point کراتی ہے. جیسے ہی یہ ہوتا ہے، %rdi کے pointed struct active stack یا red zone کا حصہ نہیں رہتا، اور kernel کے لیے off-limits بھی نہیں رہتا.

عام طور پر اس سے مسئلہ نہیں ہوتا، مگر اگر signal بالکل صحیح، یا غلط، لمحے پر آ جائے تو kernel signal frame کو %rsp-128 پر بنائے گا. یہ %rdi کے pointed memory کو overwrite کر سکتا ہے.

اگر اگلی instruction کے UC_MCONTEXT_GREGS_RIP(%rdi) پڑھنے سے پہلے ایسا ہو جائے، تو restored instruction pointer corrupt ہو سکتا ہے. ہمارے crashes میں یہ NULL ہو گیا.

یہی bug ہے.

cores عام bad returns جیسے کیوں دکھے

یہ assembly ہماری ایک confusing observation بھی explain کرتی ہے: function X کے preceding stack frame کے return address slot میں NULL کیوں تھا.

setcontext تمام registers، بشمول %rdi، restore کرنے کے لیے لکھا گیا تھا، اس لیے control transfer کے آخری لمحے میں وہ UC_MCONTEXT_GREGS_RIP(%rdi) پڑھنے کے لیے یہ register استعمال نہیں کر سکتا. اس کے بجائے یہ value پہلے پڑھتا ہے، اسے stack پر save کرتا ہے، چند اور registers restore کرتا ہے، پھر saved value پڑھ کر control transfer کرنے کے لیے retq استعمال کرتا ہے.

cores میں جو “ایک function NULL پر return ہوا” لگ رہا تھا، وہ دراصل “unwinder نے stack پر target return address synthesize کیا، مگر transfer مکمل ہونے سے پہلے وہ target corrupt ہو گیا” تھا. ہم نے فرض کیا تھا کہ return address slot کی corruption in-place ہی ہوتی ہے، کیونکہ ہمیں ایسی جگہوں کا علم نہیں تھا جہاں corruptible data جان بوجھ کر return address slot میں لکھا جاتا ہو.

ایک instruction والی race window

اس bug کو absurd بنانے والی بات race window کی تنگی ہے. اس طرح کے race condition میں external event، یعنی signal، کو دوسرے thread کے دو steps کے بیچ ہونا پڑتا ہے. یہ steps جتنے قریب ہوں، race condition کے ہونے کا امکان اتنا کم ہوتا ہے.

اس case میں vulnerable window literally ایک instruction چوڑی ہے! signal کو %rsp بدلنے کے بعد، مگر اگلی instruction کے %rip load کرنے سے پہلے deliver ہونا ضروری ہے. جدید super-scalar out-of-order CPU پر ایسی کئی simple instructions فی cycle چل سکتی ہیں، اس لیے race window تقریباً سو picoseconds کی ہے.

جب ہمیں یہ race ملی، ہمارا پہلا reaction تھا کہ observed crash rate explain کرنے کے لیے یہ بہت rare ہونی چاہیے. ہم fleet بھر میں روزانہ درجن سے زیادہ return-to-null crashes دیکھ رہے تھے. کیا exception cleanup کے دوران one-instruction race واقعی اس کی وجہ ہو سکتی تھی؟

ہم Fermat estimation کی طرف گئے. اگر vulnerable window 101010^{-10} seconds کے order کی ہے اور SIGUSR2 CPU time کے ہر 10210^{-2} seconds میں آتا ہے، تو ہر exception cleanup handler یا catch block کے race ہارنے کا probability تقریباً 10810^{-8} ہے.

Rockset exceptions کو اپنے internal ingest backpressure mechanism کے حصے کے طور پر استعمال کرتا ہے. ایک overloaded host فی second 10410^{4} کے order کی exceptions throw کر سکتا ہے. اس کا مطلب ہے کہ backpressure استعمال کرنے والے host کا mean time between failures 10410^{4} seconds ہے، یعنی ہر چند گھنٹوں میں ایک crash. fleet scale پر یہ observed crash frequency explain کرنے کے لیے کافی سے زیادہ ہے.

libunwind bug اب کیوں ظاہر ہوا؟

GNU libunwind bug پرانا ہے، 18 سال سے بھی زیادہ؛ یہ C++ exception unwinding support کرنے والے پہلے x86_64 version میں موجود تھا.

تو پھر یہ اب کیوں سامنے آیا؟

crash rate تقریباً اس کے proportional ہے کہ کتنی exceptions throw ہوتی ہیں اور کتنے signals deliver ہوتے ہیں. یہ اس پر بھی depend کرتا ہے کہ signal handler کتنا stack consume کرتا ہے.

Rockset تینوں axes پر unusual ہے. ہم normal overload control کے حصے کے طور پر high rates پر exceptions throw کرتے ہیں؛ ہم coarse_thread_cputime_clock کی وجہ سے SIGUSR2 غیر معمولی کثرت سے deliver کرتے ہیں؛ اور اس سال کے شروع میں merged signals account کرنے کے لیے timer_getoverrun کی call add کر کے SIGUSR2 handler کو زیادہ stack use کرنے لگایا.

یہ آخری change اہم لگتا ہے. اگر handler کافی کم stack use کرے تو ہو سکتا ہے وہ stale ucontext_t memory تک پہنچ کر اسے overwrite نہ کرے. اس change سے پہلے ہمیں یہ crashes بالکل نظر نہیں آتے. change کے بعد rate کم رہا، یہاں تک کہ ہم نے کچھ use cases کے لیے load بڑھایا جنہوں نے backpressure mechanism پر stress ڈالا.

دوسرے لفظوں میں، libunwind bug ہمیشہ موجود تھا، مگر ہماری exception rate، signal rate، اور handler stack usage کا حاصل ضرب حال ہی میں اس threshold سے گزرا جہاں یہ operationally visible ہو گیا.

یہ mechanism اس اتفاق کو بھی explain کرتا ہے کہ hardware bug اور libunwind bug دونوں زیادہ تر DocumentTree::updateDocument کے اندر crash ہوئے. libunwind کے crashes اس method کی طرف strongly biased تھے، کیونکہ ingest backpressure apply کرنے کے لیے exception throw کرتے وقت یہ ہمیشہ active ہوتا ہے. %rsp-misalignment crashes کے لیے بھی یہ heavily selected تھا، کیونکہ خراب hardware نوڈ ایک ایسے SKU کا تھا جسے ہم bulk ingest کے لیے استعمال کرتے ہیں، اور وہ اپنا زیادہ تر CPU time اسی method میں گزارتا ہے.

ہماری فوری mitigation GNU libunwind سے libgcc کے unwinder پر switch کرنا تھی. یہ اپنے آپ میں اچھا trade تھا: libgcc کی implementation کو lock contention کم کرنے پر بہت کام سے فائدہ ہوا ہے، جو large VMs تک scale کرتے وقت اہم ہے.

ہم نے GNU libunwind کو ایک self-contained reproducer اور fix(نئی ونڈو میں کھلتا ہے) بھی upstream کیا، اور verify کیا کہ دوسرے unwinders میں ایسا issue نہیں ہے.

population-level diagnosis کی طاقت

اس debugging journey نے ہمیں dynamic linking، DWARF unwind metadata، Linux signal delivery، System V ABI، اور C++ exception machinery کی specific details کے بارے میں بہت کچھ سکھایا. مگر اصل سبق اس سب سے زیادہ simple تھا.

سب سے اہم step clever assembly reading یا details کا deep knowledge نہیں تھا. وہ high-quality data set بنانا تھا. اس data set کے بغیر ہم دو distinct phenomena کو ایک story میں ملا رہے تھے اور confusion سے reasoning کے ذریعے نکلنے کی کوشش کر رہے تھے. جب ہمارے پاس accurate اور complete population data آ گیا، مسئلے کی structure obvious ہو گئی: ایک crash population bad host سے متعلق تھی، اور دوسری libunwind کی race سے. data بہتر ہوتے ہی debugging آسان ہو گئی.

Rockset جیسے infrastructure systems کے لیے یہ بہت اہم ہے. اس investigation نے deep instrumentation، automated investigations، اور ہمارے operational tooling میں continuous improvements کے لیے ہماری commitment کو مضبوط کیا. Reliability صرف bugs ہونے کے بعد انہیں fix کرنے کا نام نہیں؛ یہ data، workflows، اور skills بنانے کا نام ہے جو ناممکن مسائل کو diagnosable اور solvable بنا دیتے ہیں.

مصنفین

By Nathan Bronson، Member of Technical Staff