Debugging and Systematic Troubleshooting Questions
Diagnosing defects methodically: reproducing failures, forming and testing hypotheses, reading stack traces and logs, bisecting changes, and reasoning about error handling and edge cases. Covers a disciplined root-cause approach that applies from local bugs to production issues, distinct from embedded hardware-level debugging. A universally probed engineering-craft skill.
A long-running C++ process intermittently crashes with heap corruption. Describe a diagnostic plan that includes running under ASAN or Valgrind, enabling core dumps, analyzing stack traces and allocator patterns, and identifying buffer overflows or use-after-free bugs, plus strategies to fix and validate such memory-safety issues in CI.
Sample Answer
Direct answer
Diagnosing heap corruption in a long-running C++ process means catching the corruption AT THE POINT IT HAPPENS, not at the point it eventually crashes, because by the time a corrupted heap causes a visible crash the actual buggy write can be far away in both time and code from the crash site. The standard toolchain: run under AddressSanitizer (ASAN) or Valgrind to catch buffer overflows and use-after-free at the exact instruction that causes them, enable core dumps for any crash that does occur, and analyze allocator/heap-metadata corruption patterns to distinguish overflow from use-after-free from double-free.
Structured elaboration
- Reproduce under a memory-safety sanitizer first, since manual code review rarely finds heap corruption directly. ASAN instruments every memory access and reports the exact read/write, its size, and a full stack trace the moment an out-of-bounds or use-after-free access occurs, rather than only when the corrupted memory later causes a visible crash. Valgrind's memcheck provides similar coverage without recompilation, at a significant runtime-speed cost, useful when you cannot rebuild with sanitizer flags (e.g., against a shipped binary).
- Enable core dumps (
ulimit -c unlimitedand a configured core pattern) so that if the process does crash outside a sanitizer run, you have a snapshot to analyze post-mortem rather than only a stack trace at the crash site, which for heap corruption is frequently NOT where the actual bug is. - Distinguish the failure category from the corruption pattern. A buffer overflow typically corrupts adjacent heap metadata or neighboring allocations (found by ASAN's "heap-buffer-overflow" report, pinpointing both the overflowing write and the allocation it overflowed); a use-after-free corrupts memory that has already been returned to the allocator and possibly reused by something else (ASAN's "heap-use-after-free," which also reports where the memory was freed); a double-free corrupts the allocator's own internal free-list structures and often crashes deep inside
malloc/freerather than in application code. - Analyze allocator patterns for intermittent, hard-to-reproduce cases. If the corruption is rare enough that a full sanitizer run in production isn't practical, techniques like periodic heap-consistency checks (lighter-weight, built-in memory-allocator self-checks you can turn on without a full sanitizer run:
malloc_zone_checkon macOS,mallopt(M_CHECK_ACTION, ...)on glibc-based Linux) or a hardened allocator (e.g., Electric Fence, or glibc's tunable malloc debugging) can narrow down. These are fallback options for when a full ASAN/Valgrind run genuinely isn't practical; in most cases the sanitizer approach described above is what you'd actually reach for first when corruption first occurs without full ASAN overhead. - Fix and validate under the sanitizer, not just by re-running normally. A fix that makes the crash go away without re-confirming under ASAN can just mean the corruption still happens but no longer crashes visibly, which is worse: the bug is still there, now hidden again.
- Validate in CI, not just once by hand. A one-off local ASAN run proves the fix today; it does not stop the same bug class from coming back next quarter. Add a dedicated CI job that builds the affected binary (or its unit/integration test suite) with
-fsanitize=addressand runs it on every pull request, and treat any sanitizer finding as a build-blocking failure rather than a warning. Because ASAN's overhead (roughly 2-3x) is usually acceptable for a test suite even when it would be too costly for production, this is normally affordable as a required CI check; if the full suite is too slow to run on every commit, run it on a schedule (e.g., nightly) against main, with new-code paths covered on every PR at minimum.
Worked example
A service crashes roughly once a week with no consistent stack trace, a classic heap-corruption signature. Running the exact same workload under ASAN in a staging environment reproduces a heap-buffer-overflow within minutes: a fixed-size buffer used to serialize a variable-length message is written one byte past its allocation when the message hits a specific length boundary. ASAN's report gives the overflowing write's stack trace directly, which is not the same location as any of the crash sites seen in production, confirming why the crash location alone was misleading: production was crashing wherever the corrupted metadata happened to be read next, sometimes far from the actual bug.
Trade-offs and pitfalls
ASAN and Valgrind both add significant CPU and memory overhead (roughly 2-3x for ASAN, often 10-20x for Valgrind), so neither is normally run in production continuously; the practical approach is reproducing the workload in staging under the sanitizer, or running a canary instance with ASAN enabled if the bug is too rare to reproduce off of production traffic. Treating the crash-site stack trace as the bug location, without sanitizer confirmation, is the single most common way heap-corruption investigations go in circles.
Given a function that sometimes throws an exception deep inside a library you cannot modify, explain how you would instrument the codebase to capture a stack trace and the relevant contextual variables without changing the library's own code. Describe approaches for at least two of Python, Java, or Node.js.
Sample Answer
Direct answer
You can capture a stack trace and the relevant local state without touching the library's source by instrumenting at the BOUNDARY where you call into it: wrap the call site in a try/catch (or its language equivalent) that re-raises after logging, install a global/uncaught-exception hook that fires regardless of where the exception originates, or attach a debugger/tracer that breaks on the exception type without modifying any code at all.
Structured elaboration
Three complementary approaches, from least to most invasive:
- Wrap the call site, not the library. Since you control the code that CALLS into the library, even if you can't modify the library itself, catching the exception at your own call site and logging the full stack trace, the exception type/message, and any locally-relevant variables (the arguments you passed in, the current request context) before re-raising gives you a permanent, low-overhead capture point. In Python:
try: library_call(...) except Exception: logger.exception("context: %s", relevant_vars); raise. In Java: a try/catch around the call that logs viae.printStackTrace()or a structured logger before rethrowing. In Node.js: wrapping in a try/catch for synchronous calls, or.catch()on the returned Promise for async ones. - Install a global uncaught-exception/unhandled-rejection hook. This catches cases where the exception surfaces somewhere you didn't anticipate wrapping: Python's
sys.excepthook, Java'sThread.setDefaultUncaughtExceptionHandler, Node'sprocess.on('uncaughtException', ...)andprocess.on('unhandledRejection', ...). These fire regardless of exactly where inside the library the exception originated, at the cost of being a last-resort catch-all rather than a precisely-scoped one. - Attach a debugger or tracer without changing code, when you need MORE than a stack trace, such as full local variable state at the moment of the throw. Most debuggers support "break on exception" (Python's
pdbwithpython -m pdb, orimport pdb; pdb.set_trace()combined with a signal, or a conditional breakpoint set on the exception type in a full IDE debugger; Java's debugger supports exception breakpoints directly in most IDEs) which pauses execution at the exact throw site, inside the library's own code, letting you inspect the full call stack and locals without ever editing the library.
Worked example
A third-party HTTP client library occasionally raises an unlabeled ConnectionError with no useful message, and you need to know what request triggered it. Wrapping the call site: try: response = third_party_client.get(url, timeout=5) except ConnectionError: logger.exception("third_party_client failed for url=%s, timeout=%s", url, 5); raise. This doesn't change a single line of the library, but every future occurrence now logs the exact URL and timeout that triggered it, alongside the library's own stack trace, immediately giving you the context needed to distinguish "this URL is consistently unreachable" from "this only fails under a specific timeout value."
Trade-offs and pitfalls
A global uncaught-exception hook is a safety net, not a substitute for a targeted wrap at the call site: it tells you SOMETHING failed somewhere, but without the call-site context (what arguments were in play, what business operation was in progress) it's often not enough to actually diagnose the issue, only to know it happened. The debugger approach is the most informative but requires either a reproducible local trigger or an interactive session, so it's best reserved for cases where the logged stack trace alone isn't enough to form a hypothesis.
Find and explain the bug in this Python function, then provide a corrected implementation:
def append_item(item, lst=[]):
lst.append(item)
return lst
# append_item(1) -> [1]
# append_item(2) -> [1, 2] # unexpected: shared across calls
Explain why the sharing happens and show the safe fix for the default argument.
Sample Answer
Direct answer
The bug is that lst=[] creates the default list once, at function-definition time, not once per call. Every call that omits the lst argument reuses the exact same list object, so items accumulate across calls that were never meant to share state. The fix is to use None as the sentinel default and create a fresh list inside the function body when no list was passed.
Structured elaboration
In Python, default argument values are evaluated exactly once, when the def statement executes, and the resulting object is bound to the function itself. A mutable default (a list, dict, or set) is therefore the SAME object across every call that relies on the default, so mutating it in one call is visible in the next. This is a well-known Python pitfall precisely because the code reads as if a fresh list is created per call, which is the intuitive (and correct) behavior for immutable defaults like None, 0, or "", but not for mutable ones.
Worked example
def append_item_buggy(item, lst=[]):
lst.append(item)
return lst
def append_item_fixed(item, lst=None):
if lst is None:
lst = []
lst.append(item)
return lst
print("BUGGY:")
print(append_item_buggy(1))
print(append_item_buggy(2))
print("FIXED:")
print(append_item_fixed(1))
print(append_item_fixed(2))
Executed output:
BUGGY:
[1]
[1, 2]
FIXED:
[1]
[2]
The buggy version's second call returns [1, 2] instead of the expected [2], because it's still appending to the exact same list object created back when the function was defined. The fixed version creates a brand-new list inside the function body on every call where the caller didn't explicitly pass one, so each call starts clean.
Trade-offs and pitfalls
This bug is especially dangerous because it's silent and cumulative: a function like this can appear to work correctly in isolated tests (each test creates its own scenario and might not notice the shared state) and only manifest as data mysteriously growing or leaking between unrelated calls once the function is used repeatedly in a long-running process, which is exactly the kind of "works in tests, fails in production" gap this class of bug produces. The general rule: never use a mutable object (list, dict, set, or a custom mutable class instance) as a default argument value; use None and construct the mutable object inside the function body instead.
Provide a succinct example of a recent small bug you fixed. Include the minimal code before and after in a language of your choice, explain the root cause, why your fix works, how you tested it, and what you learned that will help you avoid similar bugs in the future.
Sample Answer
Direct answer
A recent example: a function computing a running total returned a subtly wrong result specifically when passed an empty input list, because it assumed at least one element would always be present; the root cause was an unchecked assumption about the input's shape, not a logic error in the calculation itself, and the fix was a one-line explicit guard plus a test covering the previously-unconsidered case.
Structured elaboration and worked example
Before:
def average_response_time(durations):
total = sum(durations)
return total / len(durations)
This looks correct and passes any test using a non-empty list. The bug: called with an empty list (a genuinely possible input, e.g., a time window with zero recorded requests), it raises ZeroDivisionError, which in this specific case was crashing a reporting job whenever a low-traffic service had a quiet hour with literally zero requests, a real, recurring production scenario the original code never considered.
After:
def average_response_time(durations):
if not durations:
return None # no data for this window; caller decides how to display that
total = sum(durations)
return total / len(durations)
Root cause: the function was written and tested against realistic-looking sample data, which always happened to be non-empty, so the implicit assumption "there's always at least one duration" was never challenged during development; the bug surfaced only once real production traffic included a genuinely empty window, an edge case that's easy to overlook precisely because it's uncommon rather than because it's hard to reason about once you think to check for it.
How it was tested: added an explicit unit test calling the function with an empty list and asserting it returns None rather than raising, alongside the existing non-empty-input tests, so the specific edge case that caused the production issue is now permanently covered and can't silently regress.
What was learned to avoid similar bugs: for any function that aggregates over a collection, explicitly consider and test the empty-collection case as a matter of habit, not just when a bug report forces the question; more broadly, the pattern generalizes to any input assumption implicit in code (non-null, non-negative, within some expected range) that reads as "obviously always true" during development but isn't guaranteed by the function's actual contract, and is worth deliberately challenging each such assumption with a test rather than trusting that realistic-looking sample data will happen to cover it.
Trade-offs and pitfalls
The fix here (returning None for an empty input) is a specific design choice, not the only valid one; an alternative would be raising a more informative, explicit exception (ValueError("cannot average an empty list")) if silently returning None risks a caller mishandling it further downstream without noticing. The right choice depends on what callers actually need to do with a "no data" result, and is worth deciding deliberately rather than defaulting to whichever felt fastest to write.
Your payment provider intermittently returns 502 errors, causing checkout failures for customers. Describe the debugging steps you would take to determine whether the root cause is your own integration, the provider itself, the network, or a configuration issue, and describe short-term mitigations to reduce customer impact while you investigate.
Sample Answer
Direct answer
First establish WHO owns the fault, before trying to fix anything: check whether the 502s originate from your own service (a bug in how you call the provider), the network path between you and them, your configuration (wrong endpoint, expired credentials, timeout misconfigured), or the provider itself. A 502 specifically means an upstream server returned an invalid response to a gateway, which already narrows the search: it usually means something between you and the provider's actual application server broke, not that your request was malformed (that would more often be a 4xx).
Structured elaboration
- Check your own logs for the exact request/response pair. Capture the full request you sent (headers, body, timing) and the full 502 response, including any body the provider returned. A 502 with a body from the provider's own error page suggests an issue near their edge, not deep in their systems.
- Check timing and correlation. Is the failure rate correlated with your request VOLUME (suggests rate limiting or a capacity issue on their side), with TIME OF DAY (suggests their scheduled maintenance or your own traffic pattern), or with a SPECIFIC request shape (suggests your own payload triggers an edge case in their processing)?
- Rule out your own network and configuration. Confirm DNS resolves to the expected endpoint, TLS handshakes succeed, and you're not accidentally hitting a sandbox/staging URL in production. Check whether a recent config or credential change on your side coincides with when the 502s started.
- Check the provider's status page and support channels. Many payment providers publish real-time incident status; if others report the same symptom at the same time, that's strong evidence the fault is upstream, not yours.
- Determine if it's truly intermittent or has a pattern. A steady low background rate of 502s can be normal for any third-party dependency at scale; a sudden step-change is what actually indicates an incident, on either side.
Worked example
A checkout service seeing 502s from a payment provider: logs show the failures are NOT correlated with request volume (rules out simple rate limiting) but ARE correlated with a specific payment method (Apple Pay tokens specifically), while card payments succeed at the normal rate. That pattern points at the provider's Apple-Pay-specific processing path, not a general outage or a problem in the checkout service's own code, since the same service, same network path, and same general request shape succeed for card payments. Confirmed by checking the provider's status page, which shows a partial incident affecting exactly that payment method.
Trade-offs and pitfalls
Short-term mitigation while you investigate: implement a retry with backoff for 502s specifically (a 502 is often transient), and if you can identify a stable pattern like the Apple-Pay-specific example, temporarily route that payment method to a fallback or clearly surface the failure to the user rather than silently retrying a request that will keep failing the same way. The trap is assuming "third-party" automatically means "not my problem to investigate further": even a genuine provider-side incident is worth root-causing on your end, both to build an accurate mitigation and because sometimes what looks like a provider outage is actually your own malformed request that only fails for a specific payload shape.
Unlock Full Question Bank
Get access to all 42 Debugging and Systematic Troubleshooting interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.