ಮುಖ್ಯ ವಿಷಯಕ್ಕೆ ನೇರವಾಗಿ ಹೋಗಿ
OpenAI

Core dump epidemiology: fixing an 18-year-old bug

Using population-level analysis to debug tricky crashes in our data infrastructure.

ಲೋಡ್ ಆಗುತ್ತಿದೆ…

By Nathan Bronson, Member of Technical Staff

OpenAI’s models and agents increasingly rely on scalable data infrastructure in order to search for relevant data at inference time: when the models are thinking about your question. Some of these services are written in C++, whose low-level control of the system lets us maximize performance and minimize memory usage. Those efficiency benefits are important as we scale, but C++’s lack of memory safety means that bugs can cause crashes by writing to incorrect or non-existent memory addresses.

A few months ago we observed some crashes from inside the Rockset service, a bespoke part of our ChatGPT data infrastructure which is key to many data plugins and to searching over conversations. In each of these crashes, a normal C++ function seemed to finish and then return to a bogus address, causing the kernel to stop the program because the instruction pointer no longer pointed at code. Sometimes the return address slot in the stack frame was NULL. Sometimes the stack pointer CPU register itself seemed to be off by 8 bytes, as if %rsp had somehow been decremented in the middle of normal execution. In both cases the crash happened on return.

These are not normal failure modes for application code. A stray write that lands only on a saved return address is possible, but extremely unlikely. A bug that misaligns %rsp by 8 without involving inline assembly, setcontext, or longjmp (none of which we use) is even stranger, because compiled code only adjusts that register directly in the function prologue and epilogue. Every hypothesis we (or ChatGPT) could think of had strong evidence against it, so the bug seemed impossible.

What we assumed was one problem eventually turned out to be two unrelated bugs, coincidentally discovered at the same time. First, silent hardware corruption on one Azure host, where the CPU just didn’t do math correctly. Second, an 18-year-old race condition in GNU libunwind, an unnoticed bug in a widely used open source library.

This post is the story of how we identified and fixed seemingly inexplicable crashes by thinking like an epidemiologist and building a high-quality data set about the entire population of crashes.

First debugging attempt: carefully examining a few core dumps

First, let’s go deeper on Rockset. It’s a cloud-native data system for search and real-time analytics that we use for many internal use cases at OpenAI, such as sync connectors (Rockset was acquired by OpenAI in 2024). Streaming updates are used to maintain an up-to-date index of a workspace’s knowledge base so that ChatGPT can search for relevant information when answering questions or performing actions.

Rockset’s execution layer is written in C++. The C++ language provides low-level access to the CPU, which is good for performance and efficiency, but it means that application bugs can lead to invalid memory accesses and segfaults. To help track these down we use folly’s fatal signal handler to log a stack trace when a crash happens, and we upload the corresponding core dumps (a snapshot of the state of the program when it crashed) to Azure blob storage for later analysis. All of Rockset’s query processing leaves are replicated, which minimizes the client impact of a crash. However, each segfault corresponds to a bug that needs to be fixed to meet our reliability and quality goals.

Our initial approach was to treat these cores like a conventional debugging problem: inspect a few core dumps very closely, form hypotheses, and rule them out one by one.

Most of the crashes occurred in a method called DocumentTree::updateDocument. In these crashes it appeared that updateDocument had called some unknown function X, the stack had become corrupted while X was active, then X had returned to an address that wasn’t executable code. In some cases X’s just-popped frame looked valid except that its saved return address was NULL. In other cases the stack pointer itself looked wrong, but the next valid frame still seemed to be updateDocument.

We didn’t know when the stack was getting corrupted, which left a huge search space. updateDocument is a large method that undergoes a lot of inlining, so the number of candidates for X was overwhelming.

Was this a bug in our C++ code? A compiler or linkage issue? A problem in one of our runtime libraries? A Linux kernel bug around signal delivery or context switching? Something even rarer? If this was a stray write, why wasn’t it caught by our ASAN staging environment?

We tried to use our application-level logs to identify all occurrences of the problem, but stack-corruption bugs are hard to classify from logs alone because the logged stack traces are themselves corrupted or missing. We weren’t able to construct a log query that didn’t have both false positives and false negatives. We manually inspected more cores and found some additional examples, but that process was too labor-intensive to give us a trustworthy data set.

At this stage of the investigation, we (incorrectly) ruled out a hardware bug, because we saw crashes across multiple regions and multiple hardware types, so we were still looking for software-only causes. For a few days, we went super-deep on a single misaligned-%rsp crash, reconstructing the pre-crash history using stack and register contents. This produced some possible clues, but because we didn’t let go of our initial conclusions that all of the bugs had the same cause, this didn’t get us unstuck.

Clues from the stack

Before getting to the turning point of our investigation, it’s important to explain what kind of information we were extracting from the core files.

Rockset is compiled with -fno-omit-frame-pointer, so the active stack frame is always reachable through %rbp, and callers form a linked list of frame pointers.

On Linux x86_64, the AMD64 System V ABI also reserves 128 bytes below %rsp as the red zone. That region is available to userspace code and, importantly, the kernel promises not to clobber it when it delivers a signal, as part of the ABI contract.

The red zone was central to our debugging of a post-return crash, because it preserves some information from before the return. When a SIGSEGV is triggered, folly’s fatal signal handler runs on the crashing thread’s stack. Stack frames that are no longer active (because their function has returned) will get clobbered by the signal handler, except for the last 128 bytes. That’s why we can say things like “X’s just-popped stack frame looked valid, except for a NULL return address.” The red zone preserves some of the inactive frames, or sometimes just the tail of one inactive frame.

corrupted stack frames return addresses overwrite ಮಾಡಿ crashes ಉಂಟುಮಾಡಬಹುದು ಎಂದು ತೋರಿಸುವ stack diagram.

We found one misaligned-stack crash in which all of the functions involved were very small. That let us see that %rsp had become misaligned during execution of a relatively simple function, and that more calls had succeeded afterward. The program only crashed when the active function finally tried to return. None of those code paths used exceptions, inline assembly, setcontext, or longjmp, so if the stack pointer truly changed in the way the core suggested, no plausible bug in userspace code explained the issue.

That pushed us toward the kernel.

Rockset uses signals more aggressively than most programs. Query execution is broken into many lightweight tasks that exchange data. This is important for handling high-QPS workloads efficiently, but it makes per-query CPU accounting awkward as work for many queries is multiplexed onto the same thread pool.

Our solution is something we call coarse_thread_cputime_clock, which approximates clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) cheaply enough to sample at every task boundary. The timer_create API can be used to schedule a periodic signal delivery based on several notions of the passage of time, including the accumulation of CPU time. We schedule a signal (SIGUSR2) to be delivered every few milliseconds of CPU time, at which point the signal handler updates a thread-local value. Even though many tasks don’t see the coarse clock advance while they are executing, summing all of the deltas produces an unbiased estimate of the actual CPU time for a query.

Because we deliver signals so often, a rare kernel bug around context switching or signal delivery seemed plausible. We spent time reading bug reports, kernel source code, and the Azure-specific kernel patches. We tried stress tests. We weren’t able to find anything that seemed related.

At that point we decided to step back and try a different approach.

Doctor or epidemiologist?

There are two broad ways to debug a problem like this.

One is to act like a doctor of sorts: focus on one patient, run lots of tests, and try to diagnose a single case from detailed evidence.

The other is to act more like an epidemiologist: look at the entire population and ask whether there are patterns that a single case cannot reveal. Did the bug start at a specific release? Does it correlate with one hardware SKU (the specific CPU and server model), one region, or one kernel version? Are there multiple distinct clusters hiding inside what looks like one syndrome?

We had mostly been in doctor mode. The key shift was deciding that we needed to gather high-quality population data.

Cleaning the data

Our previous attempts to automatically find all of the instances of the problem failed because we were trying to use text searches over the logs. The core dumps themselves have a lot more information, but looking at them manually didn’t scale. We decided to invest the effort to build a pipeline that could automatically analyze the core dumps.

We had ChatGPT write a script that downloaded a prefix of each core file, extracted the registers, filtered known false positives using the logs, and automatically labeled the crash as return-to-null, misaligned-stack, or other. Then we ran that script in parallel over every production Rockset core dump from the previous year.

This was the turning point.

Once we had a clean data set, correlations appeared immediately. What we had been treating as one weird bug was actually two separate crash populations.

The return-to-null cores were spread across many clusters and geographic regions. Their frequency had increased recently, but there was no crisp start date and no clean infrastructure boundary.

The misaligned-stack crashes looked completely different. They all came from one region, had a clear start date, and never happened on nodes that had been running for a long time. Even though they involved multiple Azure VMs (virtual machines hosted in the cloud), the pattern looked like one physical machine with bad hardware causing problems for whichever VM happened to land on it.

ಕಾಲಕ್ರಮದಲ್ಲಿ cluster ಪ್ರಕಾರ crash rates ತೋರಿಸುವ dot plot; ಬಹುತೇಕ crashes clusters 2, 3, ಮತ್ತು 6ರಲ್ಲಿ ಕೇಂದ್ರೀಕೃತವಾಗಿದ್ದು, ಅವಧಿಯ ಕೊನೆಯಲ್ಲಿ cluster 1ರಲ್ಲಿ spike ಕಾಣುತ್ತದೆ.

That was the moment we realized we had been mentally conflating two bugs. Because we had been mixing counterexamples from both bugs, we couldn’t find a single coherent explanation.

Bug #1: ಕೆಟ್ಟ host

Kubernetes ನೋಡ್‌ಗಳು ಮತ್ತು timestampsಗಳ clean list ಸಿಕ್ಕಿದ್ದರಿಂದ, misaligned-stack crashes ಅನ್ನು ಒಂದೇ physical hostಗೆ trace ಮಾಡಲು ಸಾಧ್ಯವಾಯಿತು; ಅದನ್ನು denylist ಮಾಡುವುದು ಸುಲಭವಾಗಿತ್ತು.

ಹಲವಾರು ವಾರಗಳ stress testing ನಂತರವೂ, controlled environmentನಲ್ಲಿ ಆ hostನ register corruption ಮರುಸೃಷ್ಟಿಸಲು ನಮಗೆ ಆಗಲಿಲ್ಲ. ಆದರೆ problematic host ಅನ್ನು serviceನಿಂದ ತೆಗೆದ ನಂತರ misaligned-stack crashes ಮಾಯವಾದವು.

ದೋಷಪೂರಿತ ಹೋಸ್ಟ್ ಅನ್ನು ತೆಗೆದುಹಾಕುವುದು ಒಂದು ಶಾಶ್ವತ ಪರಿಹಾರವಲ್ಲ, ಏಕೆಂದರೆ ಇದು ಅದೇ ರೀತಿಯ ಸಮಸ್ಯೆ ಮತ್ತೊಮ್ಮೆ ಬರದಂತೆ ತಡೆಯುವುದಿಲ್ಲ. ಆದರೆ, ಭವಿಷ್ಯದಲ್ಲಿ ಇಂತಹ ಸಮಸ್ಯೆ ಮರುಕಳಿಸಿದರೆ ಅದನ್ನು ಸುಲಭವಾಗಿ ಪತ್ತೆಹಚ್ಚಿ ನಿಭಾಯಿಸಲು ಅನುಕೂಲವಾಗುವಂತೆ ನಾವು ಸಾಫ್ಟ್‌ವೇರ್ ಅನ್ನು ಬದಲಾಯಿಸಬಹುದು. ನಾವು ನಮ್ಮ ಫೇಟಲ್ ಸಿಗ್ನಲ್ ಹ್ಯಾಂಡ್ಲರ್ ಅನ್ನು ರಿಜಿಸ್ಟರ್ ಸ್ಟೇಟ್ (register state) ಒಳಗೊಳ್ಳುವಂತೆ ಸುಧಾರಿಸಿದ್ದೇವೆ; ಇದರಿಂದಾಗಿ ಸಮಸ್ಯೆ ಮರುಕಳಿಸುವುದನ್ನು ಕೇವಲ ಲಾಗ್‌ಗಳಿಂದಲೇ (logs) ಪತ್ತೆಹಚ್ಚಬಹುದು (ಯಾವುದೇ ಕೋರ್ ಡಂಪ್ ಅಗತ್ಯವಿರುವುದಿಲ್ಲ). ನಾವು ಕಂಟ್ರೋಲ್ ಪ್ಲೇನ್ ಅನ್ನು ಬದಲಾಯಿಸಿದ್ದು, ಇನ್ನು ಮುಂದೆ ವಿಎಮ್‌ಗಳನ್ನು (VMs) ರಿಸೈಕಲ್ ಮಾಡುವ ಬದಲಿ ಸಾಮಾನ್ಯವಾಗಿ ಮರುಬಳಕೆ (reuse) ಮಾಡಲಾಗುತ್ತದೆ. ಇದು ನಮ್ಮ ಇನ್‌ಫ್ರಾಸ್ಟ್ರಕ್ಚರ್ ಸ್ಟ್ಯಾಕ್ ಮಟ್ಟದಲ್ಲಿ ದೋಷಪೂರಿತ ನೋಡ್‌ಗಳನ್ನು ಪತ್ತೆಹಚ್ಚುವುದನ್ನು ಇನ್ನಷ್ಟು ಸುಲಭಗೊಳಿಸುತ್ತದೆ. ನಾವು ಈ ಸಾಧ್ಯತೆಯನ್ನು ಒಳಗೊಂಡಿರುವಂತೆ ನಮ್ಮ ರನ್‌ಬುಕ್‌ಗಳನ್ನು (ಮತ್ತು ನಮ್ಮ ತಂಡದ ಆಲೋಚನಾ ಮಾದರಿಗಳನ್ನು) ಸಹ ಅಪ್‌ಡೇಟ್ ಮಾಡಿದ್ದೇವೆ.

ದೋಷಪೂರಿತ ಹೋಸ್ಟ್‌ಗೆ (bad-host) ಸಂಬಂಧಿಸಿದ ಕ್ರ್ಯಾಶ್‌ಗಳನ್ನು ಪ್ರತ್ಯೇಕಿಸಿದ ನಂತರ, ಉಳಿದ ರಿಟರ್ನ್-ಟು-ನಲ್ (return-to-null) ಕೋರ್‌ಗಳ ಹಿಂದಿನ ಕಾರಣಗಳನ್ನು ಅರ್ಥಮಾಡಿಕೊಳ್ಳುವುದು ಇನ್ನಷ್ಟು ಸುಲಭವಾಯಿತು. ಇದಕ್ಕೂ ಮುನ್ನ, ನಮ್ಮ ಬಳಿ ಕೌಂಟರ್ ಎಕ್ಸಾಂಪಲ್‌ಗಳು (ವಿರುದ್ಧ ಉದಾಹರಣೆಗಳು) ಇವೆ ಎಂಬ ಕಾರಣಕ್ಕೆ ನಾವು ಎಕ್ಸೆಪ್ಷನ್ ಅನ್‌ವೈಂಡಿಂಗ್ (exception unwinding) ಸಾಧ್ಯತೆಯನ್ನು ತಳ್ಳಿಹಾಕಿದ್ದೆವು: ಅಂದರೆ ಎಕ್ಸೆಪ್ಷನ್‌ಗಳನ್ನು ಖಚಿತವಾಗಿಯೂ ಬಳಸದ ಕೋಡ್ ಪಾತ್‌ಗಳಲ್ಲಿ ಸಂಭವಿಸಿದ ಕ್ರ್ಯಾಶ್‌ಗಳು ಅವುಗಳಾಗಿದ್ದವು. ಆದರೆ ಆ ಎಲ್ಲಾ ಕೌಂಟರ್ ಎಕ್ಸಾಂಪಲ್‌ಗಳು ಹಾರ್ಡ್‌ವೇರ್-ಕರಪ್ಷನ್ ಕ್ಲಸ್ಟರ್‌ಗೆ (hardware-corruption cluster) ಸೇರಿದವಾಗಿದ್ದವು.

ಅದನ್ನು ಮನಸ್ಸಿನಲ್ಲಿ ಇಟ್ಟು ಉಳಿದ cores ಮತ್ತೆ ನೋಡಿದಾಗ, ನಮ್ಮ conclusion ನಿಖರವಾಗಿ ವಿರುದ್ಧವಾಗಿತ್ತು ಎಂದು ಕಂಡುಬಂತು: crashes ಎಲ್ಲವೂ exception unwinding ಸಮಯದಲ್ಲೇ ಆಗುತ್ತಿದ್ವು.

Exception handling ಒಂದು dynamic control transfer

C++ exception throw ಮಾಡಿದಾಗ, runtime ಯಾವ catch block ಅದನ್ನು ಸ್ವೀಕರಿಸಬೇಕು ಮತ್ತು ಮಧ್ಯದಲ್ಲಿನ ಯಾವ destructors ಅಥವಾ cleanup handlers run ಆಗಬೇಕು ಎಂಬುದನ್ನು ಕಂಡುಹಿಡಿಯಬೇಕು. compiler ಈ metadata emit ಮಾಡುತ್ತದೆ, ಆದರೆ ನಿಜವಾದ matching runtimeನಲ್ಲಿ dynamic ಆಗಿ ನಡೆಯುತ್ತದೆ.

ಎಕ್ಸೆಪ್ಷನ್ ಅನ್‌ವೈಂಡಿಂಗ್ (Exception unwinding) ಪ್ರಕ್ರಿಯೆಯು ವಾಸ್ತವವಾಗಿ throw ಅನ್ನು ಇನ್ವೋಕ್ (invokes) ಮಾಡುವ ಫಂಕ್ಷನ್‌ನಿಂದ ರನ್ ಆಗುವುದಿಲ್ಲ, ಬದಲಾಗಿ ಅದರ ಪರಿಣಾಮವಾಗಿ ಕಂಪೈಲ್ ಆದ ಕೋಡ್‌ನಿಂದ ಕಾಲ್ ಮಾಡಲ್ಪಡುವ ಹೆಲ್ಪರ್ ಫಂಕ್ಷನ್‌ಗಳಿಂದ (helper functions) ಚಲಾಯಿಸಲ್ಪಡುತ್ತದೆ. ಆ ರನ್‌ಟೈಮ್ ರೂಟೀನ್‌ಗಳು (runtime routines) ಸ್ಟ್ಯಾಕ್ ಅನ್ನು ಸೂಕ್ಷ್ಮವಾಗಿ ಪರಿಶೀಲಿಸುತ್ತವೆ, ಸ್ಟ್ಯಾಕ್‌ನಲ್ಲಿ ಕಂಡುಬರುವ ಫಂಕ್ಷನ್‌ಗಳ ಮೆಟಾಡೇಟಾವನ್ನು ಪಡೆದುಕೊಳ್ಳುತ್ತವೆ, ಡೈನಾಮಿಕ್ ಆಗಿ ಕ್ಲೀನಪ್ ಹ್ಯಾಂಡ್ಲರ್‌ಗಳು ಮತ್ತು ಕ್ಯಾಚ್ ಬ್ಲಾಕ್‌ಗಳನ್ನು (catch blocks) ಹುಡುಕುತ್ತವೆ, ಮತ್ತು ನಂತರ ಆ ಸ್ಥಳಗಳ ಪೈಕಿ ಒಂದಕ್ಕೆ ಕಂಟ್ರೋಲ್ (ನಿಯಂತ್ರಣ) ಅನ್ನು ವರ್ಗಾಯಿಸುತ್ತವೆ. ಕಂಟ್ರೋಲ್ ವರ್ಗಾಯಿಸುವ ಈ ಪ್ರಕ್ರಿಯೆಯು ನಡುವೆ ಬರುವ ಎಲ್ಲಾ ಸ್ಟ್ಯಾಕ್ ಫ್ರೇಮ್‌ಗಳನ್ನು (ಹೆಲ್ಪರ್ ಫಂಕ್ಷನ್‌ಗಳ ಫ್ರೇಮ್‌ಗಳು ಸೇರಿದಂತೆ) ಅನ್‌ವೈಂಡ್ ಮಾಡುವುದನ್ನು ಒಳಗೊಂಡಿರುತ್ತದೆ.

Operationally, ಇದು ಸಾಮಾನ್ಯ call ಮತ್ತು returnಗಿಂತ longjmp ಅಥವಾ fiber switchಗೆ ಹೆಚ್ಚು ಹತ್ತಿರ. Callee save registers ಜೊತೆಗೆ stack frame registers %rbp ಮತ್ತು %rsp ಕೂಡ restore ಆಗಬೇಕು.

ನಮ್ಮ ಬೈನರಿ (binary) ಎರಡು ಲೈಬ್ರರಿಗಳಿಗೆ ಲಿಂಕ್ ಆಗಿದೆ, ಅವುಗಳು C++ ಎಕ್ಸೆಪ್ಷನ್ ಅನ್‌ವೈಂಡಿಂಗ್ ಕಾರ್ಯವನ್ನು ನಿರ್ವಹಿಸುವ ಫಂಕ್ಷನ್‌ಗಳ ಇಂಪ್ಲಿಮೆಂಟೇಶನ್‌ಗಳನ್ನು ಒಳಗೊಂಡಿವೆ: ಅವುಗಳೆಂದರೆ libgcc ಮತ್ತು GNU libunwind. ಡೈನಾಮಿಕ್ ಲಿಂಕರ್ (dynamic linker) ಆರಿಸಿಕೊಂಡಿದ್ದು ಜಿಎನ್‌ಯು ಲಿಬ್‌ಅನ್‌ವೈಂಡ್‌ನ (GNU libunwind) ಡೆಫಿನಿಷನ್‌ಗಳನ್ನು. ಇದು ನಮಗೆ ಆಶ್ಚರ್ಯ ತಂದಿತು; ಸಿಂಬಲ್ ವರ್ಷನಿಂಗ್ ನಿಯಮಗಳ (symbol versioning rules) ಕಾರಣದಿಂದಾಗಿ libgcc ಇಂಪ್ಲಿಮೆಂಟೇಶನ್ ಆಯ್ಕೆಯಾಗಬಹುದು ಎಂದು ನಾವು ನಿರೀಕ್ಷಿಸಿದ್ದೆವು; ಆದಾಗ್ಯೂ, ಚಾಲನೆಯಲ್ಲಿರುವ ಬೈನರಿಗಳನ್ನು ಪರಿಶೀಲಿಸಿದಾಗ ಅದು ನಿಜವಲ್ಲ ಎಂದು ತಿಳಿದುಬಂದಿತು.

ಕೊನೆಯ ಒಂದು ಅಸಂಪ್ಷನ್ (ಊಹೆ) ಅನ್ನು ಹಿಂತೆಗೆದುಕೊಳ್ಳುವುದು / ರದ್ದುಗೊಳಿಸುವುದು

ಈ ಹಂತದಲ್ಲಿ, ಒಂದೇ bug ಇದೆ ಎಂದುಕೊಂಡಾಗ ಮಾಡಿಕೊಂಡಿದ್ದ ಮತ್ತೊಂದು assumption ಅನ್ನು ಸಡಿಲಿಸಿದಂತೆ, ನಮ್ಮ working hypothesis ಬದಲಾಯಿತು.

ಬಹುಶಃ ನಾವು ordinary function NULLಗೆ return ಆಗುವುದನ್ನು ನೋಡುತ್ತಿರಲಿಲ್ಲ. ಬಹುಶಃ ನಾವು unwind transfer ಅನ್ನು ನೋಡುತ್ತಿದ್ದೆವು, ಅಂದರೆ ಪರಿಣಾಮವಾಗಿ setcontext-style register restore, ಅಲ್ಲಿ control transfer ಆಗುವ ಮೊದಲು destination instruction pointer NULL ಆಗಿತ್ತು. ಬೇರೆ ಮಾತಿನಲ್ಲಿ, stackನ incorrect return address slotಗಿಂತ unwind libraryಯಿಂದ ಬಂದ incorrect data.

ಇದು ಸಮಸ್ಯೆಯನ್ನು ತುಂಬಾ ಸೀಮಿತಗೊಳಿಸಿತು. GNU libunwind ತಪ್ಪು destination state compute ಮಾಡುತ್ತಿತ್ತು, ಅಥವಾ ಸರಿಯಾದ state compute ಮಾಡಿದರೂ ಅದನ್ನು apply ಮಾಡುವ ಮೊದಲು ಏನೋ ಅದನ್ನು corrupt ಮಾಡುತ್ತಿತ್ತು.

ನಾವು GNU libunwind source ಓದಿದಾಗ, ಅದು stack ಮೇಲೆ ucontext_t synthesize ಮಾಡಿ, cleanup handlerನ frameಗೆ ಬೇಕಾದ register state ತುಂಬಿ, ಆ structಗೆ pointer ಅನ್ನು internal assembly routineಗೆ ನೀಡುತ್ತದೆ ಎಂದು ಕಂಡುಬಂತು: _Ux86_64_setcontext.

ಈ ಹಂತದಲ್ಲಿ ನಮ್ಮ ಬಳಿ ಎಲ್ಲ pieces ಇದ್ದವು.

synthesized ucontext_t, _Ux86_64_setcontext ಆ function execution ಸಮಯದಲ್ಲಿ unwind ಮಾಡುವ stack framesನೊಂದರಲ್ಲಿ ಇರುತ್ತದೆ. _Ux86_64_setcontext %rsp ಬದಲಿಸಿದ ನಂತರ structನಿಂದ ಓದುತ್ತಿತ್ತೇ? ಆಗ ಆ struct active stackನ ಭಾಗವಾಗಿರಲಿಲ್ಲ. ಅದರ ফলে ನಮ್ಮ frequent SIGUSR2 сияқты signal deliveryಯಿಂದ ಅದು clobber ಆಗುವ vulnerability ಉಂಟಾಗುತ್ತಿತ್ತು.

Bug #2: libunwind bug

ಉತ್ತರ ಹೌದು ಆಗಿತ್ತು.

ನಾವು ಬಳಸುತ್ತಿದ್ದ GNU libunwind versionನಲ್ಲಿನ _Ux86_64_setcontextನ ಕೊನೆಯ ಆರು instructions ಇಲ್ಲಿವೆ; ಇವು ಬಹುತೇಕ memoryಯಿಂದ destination registerಗೆ load ಮಾಡುವ mov instructions ಆಗಿವೆ:

ಸರಳ ಪಠ್ಯ

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 ಕಡೆ ತೋರಿಸುತ್ತದೆ, ಮತ್ತು UC_MCONTEXT_* macros ನಿರ್ದಿಷ್ಟ register stored ಆಗಿರುವ fixed offsetಗೆ expand ಆಗುತ್ತವೆ.)

ಮೊದಲ instruction race windowನ ಆರಂಭ. ಇದು active stackನ ಹೊಸ bottom ಕಡೆ ತೋರಲು %rsp update ಮಾಡುತ್ತದೆ. ಇದು ಸಂಭವಿಸಿದ ತಕ್ಷಣ, %rdi ತೋರಿಸುವ struct active stack ಅಥವಾ red zoneನ ಭಾಗವಾಗಿರುವುದಿಲ್ಲ, ಮತ್ತು kernelಗೆ ಅದು ಇನ್ನು off-limits ಅಲ್ಲ.

ಸಾಮಾನ್ಯವಾಗಿ ಇದರಿಂದ ಸಮಸ್ಯೆ ಆಗುವುದಿಲ್ಲ. ಆದರೆ signal ನಿಖರವಾಗಿ ಸರಿಯಾದ, ಅಂದರೆ ತಪ್ಪಾದ, ಕ್ಷಣದಲ್ಲಿ ಬಂದರೆ kernel %rsp-128ನಲ್ಲಿ signal frame ನಿರ್ಮಿಸುತ್ತದೆ. ಅದು %rdi ತೋರಿಸುವ memoryಯನ್ನು overwrite ಮಾಡಬಹುದು.

ಮುಂದಿನ instruction UC_MCONTEXT_GREGS_RIP(%rdi) ಓದುವ ಮೊದಲು ಅದು ನಡೆದರೆ, restored instruction pointer corrupt ಆಗಬಹುದು. ನಮ್ಮ crashesನಲ್ಲಿ ಅದು NULL ಆಯಿತು.

ಅದೇ bug.

cores ಸಾಮಾನ್ಯ bad returns ಹಾಗೆ ಏಕೆ ಕಂಡವು

ಈ assembly ನಮ್ಮನ್ನು ಗೊಂದಲಗೊಳಿಸಿದ ಒಂದು observationನ್ನೂ ವಿವರಿಸುತ್ತದೆ: function Xಗೆ ಹಿಂದಿನ stack frameನ return address slotನಲ್ಲಿ NULL ಏಕೆ ಇತ್ತು.

setcontext ಫಂಕ್ಷನ್ ಅನ್ನು %rdi ಸೇರಿದಂತೆ ಎಲ್ಲಾ ರಿಜಿಸ್ಟರ್‌ಗಳನ್ನು ರಿಸ್ಟೋರ್ (ಮರುಸ್ಥಾಪನೆ) ಮಾಡಲು ಬರೆಯಲಾಗಿದೆ; ಆದ್ದರಿಂದ ಕಂಟ್ರೋಲ್ ಟ್ರಾನ್ಸ್‌ಫರ್‌ನ (ನಿಯಂತ್ರಣ ವರ್ಗಾವಣೆ) ಅಂತಿಮ ಕ್ಷಣದಲ್ಲಿ UC_MCONTEXT_GREGS_RIP(%rdi) ಅನ್ನು ರೀಡ್ ಮಾಡಲು ಅದು ಅದೇ ರಿಜಿಸ್ಟರ್ ಅನ್ನು ಬಳಸಲು ಸಾಧ್ಯವಿಲ್ಲ. ಬದಲಾಗಿ, ಇದು ಆ ಮೌಲ್ಯವನ್ನು ಮೊದಲೇ ರೀಡ್ ಮಾಡಿ ಸ್ಟ್ಯಾಕ್‌ನಲ್ಲಿ ಸೇವ್ ಮಾಡುತ್ತದೆ, ಇನ್ನು ಕೆಲವು ರಿಜಿಸ್ಟರ್‌ಗಳನ್ನು ರಿಸ್ಟೋರ್ ಮಾಡುತ್ತದೆ, ಮತ್ತು ನಂತರ ಸೇವ್ ಮಾಡಿದ ಮೌಲ್ಯವನ್ನು ರೀಡ್ ಮಾಡಲು ಹಾಗೂ ನಿಯಂತ್ರಣವನ್ನು ವರ್ಗಾಯಿಸಲು retq ಅನ್ನು ಬಳಸುತ್ತದೆ

ನಮಗೆ ಕೋರ್ ಡಂಪ್‌ಗಳಲ್ಲಿ (core dumps) "ಒಂದು ಫಂಕ್ಷನ್ NULL ಗೆ ರಿಟರ್ನ್ ಆಗಿದೆ" ಎಂದು ಕಂಡಿದ್ದು, ವಾಸ್ತವವಾಗಿ "ಅನ್‌ವೈಂಡರ್ (unwinder) ಸ್ಟ್ಯಾಕ್‌ನ ಮೇಲೆ ಒಂದು ಟಾರ್ಗೆಟ್ ರಿಟರ್ನ್ ಅಡ್ರೆಸ್ ಅನ್ನು ಸಿಂಥಸೈಸ್ (ರಚನೆ) ಮಾಡಿತ್ತು, ಆದರೆ ನಿಯಂತ್ರಣ ವರ್ಗಾವಣೆ ಪೂರ್ಣಗೊಳ್ಳುವ ಮೊದಲೇ ಆ ಟಾರ್ಗೆಟ್ ಕರಪ್ಟ್ (ದೋಷಪೂರಿತ) ಆಗಿತ್ತು." ರಿಟರ್ನ್ ಅಡ್ರೆಸ್ ಸ್ಲಾಟ್‌ಗೆ ಉದ್ದೇಶಪೂರ್ವಕವಾಗಿ ಯಾವುದೇ (ಕರಪ್ಟ್ ಆಗಬಹುದಾದ) ಡೇಟಾವನ್ನು ಬರೆಯುವ ಯಾವುದೇ ಸ್ಥಳಗಳು ನಮಗೆ ತಿಳಿದಿಲ್ಲದ ಕಾರಣ, ರಿಟರ್ನ್ ಅಡ್ರೆಸ್ ಸ್ಲಾಟ್‌ನ ಕರಪ್ಷನ್ ಇನ್-ಪ್ಲೇಸ್ (in-place - ಅದೇ ಜಾಗದಲ್ಲಿ) ಸಂಭವಿಸಿರಬೇಕು ಎಂದು ನಾವು ಭಾವಿಸಿದ್ದೆವು.

ಒಂದು ಸಿಂಗಲ್-ಇನ್‌ಸ್ಟ್ರಕ್ಷನ್ ರೇಸ್ ವಿಂಡೋ (A single-instruction race window)

ಈ bug ಅಸಂಬದ್ಧದಂತೆ ಕಾಣಲು ಕಾರಣ, ಈ race window ಅಷ್ಟು ಕಿರಿದಾಗಿದೆ. ಈ ರೀತಿಯ race conditionನಲ್ಲಿ, external event, ಅಂದರೆ signal, ಮತ್ತೊಂದು thread ತೆಗೆದುಕೊಳ್ಳುವ ಎರಡು steps ಮಧ್ಯೆ ಸಂಭವಿಸಬೇಕು. ಆ steps ಪರಸ್ಪರ ಎಷ್ಟು ಹತ್ತಿರವೋ, race condition ಸಂಭವಿಸುವ ಸಾಧ್ಯತೆ ಅಷ್ಟು ಕಡಿಮೆ.

ಈ ಸಂದರ್ಭದಲ್ಲಿ vulnerable window ಅಕ್ಷರಶಃ ಒಂದು instruction ಅಗಲದಷ್ಟಿದೆ. %rsp ಬದಲಾಗಿದ ನಂತರ, ಆದರೆ ಮುಂದಿನ instruction %rip load ಮಾಡುವ ಮೊದಲು, signal deliver ಆಗಬೇಕು. ಆಧುನಿಕ super-scalar out-of-order CPUನಲ್ಲಿ ಇಂತಹ ಹಲವಾರು ಸರಳ instructions ಪ್ರತಿ cycleಗೆ run ಆಗಬಹುದು. ಆದ್ದರಿಂದ race window ಸುಮಾರು ನೂರು picoseconds.

ಈ race ಕಂಡಾಗ, observed crash rate ವಿವರಿಸಲು ಇದು ತುಂಬಾ ಅಪರೂಪವಾಗಿರಬೇಕು ಎಂಬುದೇ ನಮ್ಮ ಮೊದಲ ಪ್ರತಿಕ್ರಿಯೆ. fleetನಾದ್ಯಂತ ದಿನಕ್ಕೆ ಡಜನ್‌ಗಿಂತ ಹೆಚ್ಚು return-to-null crashes ಕಾಣುತ್ತಿದ್ವು. exception cleanup ಸಮಯದ ಒಂದು-instruction race ನಿಜವಾಗಿಯೂ ಅದಕ್ಕೆ ಕಾರಣವಾಗಬಹುದೇ?

ನಾವು Fermat estimationಗೆ ತಿರುಗಿದೆವು. vulnerable window 101010^{-10} seconds ಕ್ರಮದಲ್ಲಿದ್ದರೆ, ಮತ್ತು SIGUSR2 CPU timeನ ಪ್ರತೀ 10210^{-2} secondsಗೆ ಬಂದರೆ, ಪ್ರತಿ exception cleanup handler ಅಥವಾ catch blockಗೆ race ಸೋಲುವ ಸಾಧ್ಯತೆ ಸುಮಾರು 10810^{-8}.

ರಾಕ್‌ಸೆಟ್‌ (Rockset) ತನ್ನ ಆಂತರಿಕ ಇಂಜೆಸ್ಟ್‌ ಬ್ಯಾಕ್‌ಪ್ರೆಶರ್‌ ಮೆಕ್ಯಾನಿಸಮ್‌ನ (internal ingest backpressure mechanism) ಭಾಗವಾಗಿ ಎಕ್ಸೆಪ್ಷನ್‌ಗಳನ್ನು (exceptions) ಬಳಸುತ್ತದೆ. ಒಂದೇ ಒಂದು ಓವರ್‌ಲೋಡ್‌ ಆದ ಹೋಸ್ಟ್‌ ಪ್ರತಿ ಸೆಕೆಂಡಿಗೆ ಸುಮಾರು 10410^{4} ಎಕ್ಸೆಪ್ಷನ್‌ಗಳನ್ನು ಥ್ರೋ (throw) ಮಾಡಬಹುದು. ಇದರರ್ಥ ಬ್ಯಾಕ್‌ಪ್ರೆಶರ್‌ ಬಳಸುತ್ತಿರುವ ಹೋಸ್ಟ್‌ನ ವೈಫಲ್ಯಗಳ ನಡುವಿನ ಸರಾಸರಿ ಸಮಯ (mean time between failures) 10410^{4} ಸೆಕೆಂಡುಗಳು, ಅಥವಾ ಪ್ರತಿ ಕೆಲವು ಗಂಟೆಗಳಿಗೊಮ್ಮೆ ಒಂದು ಕ್ರ್ಯಾಶ್‌ ಸಂಭವಿಸುತ್ತದೆ. ಫ್ಲೀಟ್‌ ಸ್ಕೇಲ್‌ನಲ್ಲಿ (fleet scale), ನಾವು ಗಮನಿಸಿದ ಕ್ರ್ಯಾಶ್‌ಗಳ ಆವರ್ತನವನ್ನು ವಿವರಿಸಲು ಇದು ತೀರಾ ಹೆಚ್ಚಿನದ್ದಾಗಿದೆ.

libunwind bug ಈಗ ಏಕೆ ಕಾಣಿಸಿತು?

GNU libunwind bug ಹಳೆಯದು, 18 ವರ್ಷಕ್ಕಿಂತಲೂ ಹಳೆಯದು; C++ exception unwinding ಬೆಂಬಲಿಸಿದ ಮೊದಲ x86_64 versionನಲ್ಲೇ ಇತ್ತು.

ಹಾಗಾದರೆ ಅದು ಈಗ ಏಕೆ ಹೊರಬಂದಿತು?

crash rate, ಎಷ್ಟು exceptions throw ಆಗುತ್ತವೆ ಮತ್ತು ಎಷ್ಟು signals deliver ಆಗುತ್ತವೆ ಎಂಬುದಕ್ಕೆ ಸುಮಾರು proportional ಆಗಿದೆ. signal handler ಎಷ್ಟು stack ಬಳಸುತ್ತದೆ ಎಂಬುದರ ಮೇಲೂ ಅದು ಅವಲಂಬಿತವಾಗಿದೆ.

Rockset ಈ ಮೂರು ಅಕ್ಷಗಳಲ್ಲಿಯೂ ಅಸಾಮಾನ್ಯ. ಸಾಮಾನ್ಯ overload control ಭಾಗವಾಗಿ ನಾವು high ratesನಲ್ಲಿ exceptions throw ಮಾಡುತ್ತೇವೆ; coarse_thread_cputime_clock ಕಾರಣದಿಂದ SIGUSR2 ಅನ್ನು ಅಸಾಮಾನ್ಯವಾಗಿ ಹೆಚ್ಚು deliver ಮಾಡುತ್ತೇವೆ; ಮತ್ತು ಈ ವರ್ಷದ ಆರಂಭದಲ್ಲಿ merged signals accounting ಮಾಡಲು timer_getoverrun call ಸೇರಿಸಿ SIGUSR2 handler ಹೆಚ್ಚು stack ಬಳಸುವಂತೆ ಮಾಡಿದೆವು.

ಆ ಕೊನೆಯ ಬದಲಾವಣೆ ಮುಖ್ಯವಾಗಿದ್ದಂತೆ ತೋರುತ್ತದೆ. handler ಸಾಕಷ್ಟು ಕಡಿಮೆ stack ಬಳಸಿದರೆ, ಅದು stale ucontext_t memory ತಲುಪಿ overwrite ಮಾಡದೇ ಇರಬಹುದು. ಆ ಬದಲಾವಣೆಗೆ ಮೊದಲು, ಈ crashes ನಮಗೆ ಎಲ್ಲಿಯೂ ಕಾಣಿಸಲಿಲ್ಲ. ಬದಲಾವಣೆಯ ನಂತರ, backpressure mechanismಗೆ ಒತ್ತಡ ತಂದ ಕೆಲವು use casesಗೆ load ramp up ಮಾಡುವವರೆಗೆ rate ಕಡಿಮೆಯೇ ಇತ್ತು.

ಬೇರೆ ಮಾತಿನಲ್ಲಿ, libunwind bug ಸದಾ ಇತ್ತು. ಆದರೆ ನಮ್ಮ exception rate, signal rate, ಮತ್ತು handler stack usageಗಳ product ಇತ್ತೀಚೆಗಷ್ಟೇ operationally visible ಆಗುವ threshold ದಾಟಿತ್ತು.

ಈ ಮೆಕ್ಯಾನಿಸಮ್‌ ಹಾರ್ಡ್‌ವೇರ್‌ ಬಗ್‌ ಮತ್ತು ಲಿಬ್‌ಅನ್‌ವೈಂಡ್‌ (libunwind) ಬಗ್‌ ಎರಡೂ ಹೆಚ್ಚಾಗಿ DocumentTree::updateDocument ನೊಳಗೆಯೇ ಕ್ರ್ಯಾಶ್‌ ಆಗಲು ಕಾರಣವಾದ ಕಾಕತಾಳೀಯತೆಯನ್ನು ವಿವರಿಸುತ್ತದೆ. ಲಿಬ್‌ಅನ್‌ವೈಂಡ್‌ನಿಂದ ಸಂಭವಿಸಿದ ಕ್ರ್ಯಾಶ್‌ಗಳು ಈ ಮೆಥಡ್‌ನ ಕಡೆಗೇ ಹೆಚ್ಚು ವಾಲಿವೆ, ಏಕೆಂದರೆ ಇಂಜೆಸ್ಟ್‌ ಬ್ಯಾಕ್‌ಪ್ರೆಶರ್‌ ಅನ್ನು ಅನ್ವಯಿಸಲು ನಾವು ಎಕ್ಸೆಪ್ಷನ್‌ ಥ್ರೋ ಮಾಡುವ ಹಂತದಲ್ಲಿ ಇದು ಯಾವಾಗಲೂ ಸಕ್ರಿಯವಾಗಿರುತ್ತದೆ. ಅಲ್ಲದೆ, %rsp-ಮಿಸ್‌ಅಲೈನ್‌ಮೆಂಟ್‌ ಕ್ರ್ಯಾಶ್‌ಗಳಿಗೂ ಇದು ಹೆಚ್ಚಾಗಿ ಆಯ್ಕೆಯಾಗಿತ್ತು, ಏಕೆಂದರೆ ಆ ದೋಷಪೂರಿತ ಹಾರ್ಡ್‌ವೇರ್‌ ನೋಡ್‌ ನಾವು ಬಲ್ಕ್‌ ಇಂಜೆಸ್ಟ್‌ಗಾಗಿ ಬಳಸುವ ಎಸ್‌ಕೆಯು (SKU) ನದ್ದಾಗಿತ್ತು ಮತ್ತು ಅದು ತನ್ನ ಸಿಪಿಯು (CPU) ಸಮಯದ ಬಹುಪಾಲು ಭಾಗವನ್ನು ಇದೇ ಮೆಥಡ್‌ನಲ್ಲಿ ಕಳೆಯುತ್ತಿತ್ತು.

ನಮ್ಮ ತಕ್ಷಣದ mitigation GNU libunwindನಿಂದ libgccನ unwinderಗೆ ಬದಲಿಸುವುದಾಗಿತ್ತು. ಅದು ಸ್ವತಃ ಒಳ್ಳೆಯ trade ಆಗಿತ್ತು: libgcc implementationಗೆ lock contention ಕಡಿಮೆ ಮಾಡುವ ಬಹಳ ಕೆಲಸದಿಂದ ಲಾಭವಾಗಿದೆ, ದೊಡ್ಡ VMsಗೆ scale ಮಾಡುವಾಗ ಅದು ಮುಖ್ಯ.

ನಾವು ಜಿಎನ್‌ಯು ಲಿಬ್‌ಅನ್‌ವೈಂಡ್‌ಗೆ (GNU libunwind) ಸ್ವಯಂ-ಒಳಗೊಂಡಿರುವ ರಿಪ್ರೊಡ್ಯೂಸರ್ (reproducer) ಮತ್ತು ಒಂದು ಫಿಕ್ಸ್ (ಪರಿಹಾರ) ಅನ್ನು ಅಪ್‌ಸ್ಟ್ರೀಮ್ ಮಾಡಿದ್ದೇವೆ ಹಾಗೂ ಇತರ ಅನ್‌ವೈಂಡರ್‌ಗಳಲ್ಲಿ ಇಂತಹ ಯಾವುದೇ ರೀತಿಯ ಸಮಸ್ಯೆ ಇಲ್ಲದಿರುವುದನ್ನು ಖಚಿತಪಡಿಸಿಕೊಂಡಿದ್ದೇವೆ.

population-level diagnosisನ ಶಕ್ತಿ

ಈ debugging ಪ್ರಯಾಣ dynamic linking, DWARF unwind metadata, Linux signal delivery, System V ABI, ಮತ್ತು C++ exception machineryಯ ನಿರ್ದಿಷ್ಟ ವಿವರಗಳ ಬಗ್ಗೆ ನಮಗೆ ಬಹಳ ಕಲಿಸಿತು. ಆದರೆ ಮುಖ್ಯ ಪಾಠ ಅದಕ್ಕಿಂತ ಸರಳವಾಗಿತ್ತು.

ಅತ್ಯಂತ ಪ್ರಮುಖ ಹಂತ clever assembly reading ಅಥವಾ ವಿವರಗಳ ಆಳವಾದ ಜ್ಞಾನವಲ್ಲ. ಅದು high-quality data set ನಿರ್ಮಿಸುವುದಾಗಿತ್ತು. ಈ data set ಇಲ್ಲದಿದ್ದಾಗ, ನಾವು ಎರಡು distinct phenomenaಗಳನ್ನು ಒಂದೇ storyಯಲ್ಲಿ ಮಿಶ್ರಣ ಮಾಡಿ, ಆ ಗೊಂದಲದಿಂದ ತರ್ಕದ ಮೂಲಕ ಹೊರಬರಲು ಪ್ರಯತ್ನಿಸುತ್ತಿದ್ದೆವು. accurate ಮತ್ತು complete population data ಸಿಕ್ಕ ನಂತರ, ಸಮಸ್ಯೆಯ structure ಸ್ಪಷ್ಟವಾಯಿತು: ಒಂದು crash population bad hostಗೆ ಸೇರಿತ್ತು, ಮತ್ತೊಂದು libunwindನ raceಗೆ ಸೇರಿತ್ತು. data ಉತ್ತಮವಾದ ನಂತರ, debugging ಸುಲಭವಾಯಿತು.

ರಾಕ್‌ಸೆಟ್‌ (Rockset) ನಂತಹ ಇನ್‌ಫ್ರಾಸ್ಟ್ರಕ್ಚರ್ ಸಿಸ್ಟಮ್‌ಗಳಿಗೆ ಇದು ತುಂಬಾ ಪ್ರಾಮುಖ್ಯತೆಯನ್ನು ಪಡೆದಿದೆ. ಈ ಸಂಪೂರ್ಣ ತನಿಖೆಯು ಡೀಪ್ ಇನ್‌ಸ್ಟ್ರುಮೆಂಟೇಶನ್ (ಆಳವಾದ ವ್ಯವಸ್ಥೆ), ಆಟೋಮ್ಯಾಟಿಕ್ ಇನ್ವೆಸ್ಟಿಗೇಷನ್ (ಸ್ವಯಂಚಾಲಿತ ತನಿಖೆಗಳು) ಮತ್ತು ನಮ್ಮ ಆಪರೇಷನಲ್ ಟೂಲಿಂಗ್‌ನಲ್ಲಿ ನಿರಂತರ ಸುಧಾರಣೆಗಳನ್ನು ತರುವ ನಮ್ಮ ಬದ್ಧತೆಯನ್ನು ಇನ್ನಷ್ಟು ಬಲಪಡಿಸಿದೆ. ವಿಶ್ವಾಸಾರ್ಹತೆ (Reliability) ಎಂದರೆ ಕೇವಲ ಬಗ್‌ಗಳು ಸಂಭವಿಸಿದ ನಂತರ ಅವುಗಳನ್ನು ಫಿಕ್ಸ್ ಮಾಡುವುದು ಮಾತ್ರವಲ್ಲ—ಅದು ಅಸಾಧ್ಯವೆನಿಸುವ ಸಮಸ್ಯೆಗಳನ್ನು ಸುಲಭವಾಗಿ ಪತ್ತೆಹಚ್ಚಿ ಪರಿಹರಿಸಬಹುದಾದ ಸಮಸ್ಯೆಗಳನ್ನಾಗಿ ಪರಿವರ್ತಿಸುವ ಡೇಟಾ, ವರ್ಕ್‌ಫ್ಲೋಗಳು ಮತ್ತು ಕೌಶಲ್ಯಗಳನ್ನು ನಿರ್ಮಿಸುವುದಾಗಿದೆ.

ಲೇಖಕರು

By Nathan Bronson, Member of Technical Staff