skip to content

An object pool hands back recycled response buffers, and a response occasionally carries bytes from an earlier request. Why?

level: seniorimportance: should knowfreq 50%

answer

  1. reuse without a reset
  2. the previous borrower's bytes remain
  3. the shorter response exposes the longer one
  4. length reset matters more than payload
  5. clear on release or on acquire, never both halves

basics

~20 s

A recycled cell arrives holding whatever the previous borrower left in it. If the new borrower writes fewer bytes than the old one and the consumer reads the cell's capacity or a stale length, the untouched tail is the previous request's data.

solid answer

~50 s

A pool does not clear cells; it circulates them. `acquire` returns a cell whose bytes and fields are exactly what the last borrower left, so correctness depends on somebody resetting it, and the usual miss is not the payload but the **length**. Take an 8 KB cell: the previous response wrote 6000 bytes, the new one writes 2000, and if the writer never reset the length field — or the consumer reads capacity instead of length — bytes 2000 to 5999 are still the previous response, and they are shipped. The fix is to pick one discipline and apply it on every path: **clear on release** (the returner wipes the cell and zeroes its length before it rejoins the free set) or **clear on acquire** (the borrower does it), never a mixture where each side assumes the other did it. Clear on release is usually preferred, because it also stops one request's data sitting in a warm cell until the next borrower arrives.

code

pseudocode · 14 lines
pseudocode
function acquire(pool):
    cell = take_free_cell(pool)
    if cell == none:
        cell = new_cell(pool.cell_size)   # only a brand-new cell is clean
    cell.in_use = true
    return cell

function release(pool, cell):
    assert cell.in_use                    # catches a double return
    wipe(cell.bytes, 0, cell.used_high_water)
    cell.length = 0
    cell.used_high_water = 0
    cell.in_use = false
    put_free_cell(pool, cell)

go deeper

for a junior

Remember that a recycled object is not a new one: it still holds the previous user's contents until something resets it.

for a middle

Explain the mechanism with the numbers — a shorter write into a cell that held a longer one leaves the old tail readable — and name the two clearing disciplines.

for a senior

Demonstrate that you would pick one discipline, enforce it on error paths too, and separate this from double-return and use-after-return, which clearing cannot fix.

for a principal

Treat it as a class: bound every consumer's view by the length so stale bytes are unreadable by construction, and decide what clearing cost the fleet pays for that guarantee.

## What acquire actually returns The whole point of a pool is that it does not create anything: it hands back a cell that has been used before. That cell carries its previous contents — the payload bytes, and every field beside them, including length, position, status flags, an error slot, a reference to something the last borrower attached. Nothing in the pool mechanism clears any of that. So a pooled object is **correct only if some agreed step makes it look new**, and cross-request data exposure is what happens when that step is missing or incomplete. This is a correctness and a confidentiality problem at the same time, which is why interviewers like it: the bug does not corrupt anything, it silently ships one user's bytes to another. ## The length field is the usual culprit Work the arithmetic, because it explains why the bug is intermittent. - The cell's capacity is 8192 bytes. - Borrower A writes a 6000-byte response and sets the length to 6000. - A returns the cell. The bytes are untouched; the length still reads 6000. - Borrower B writes a 2000-byte response into offsets 0 to 1999. - If B forgets to set the length, or the consumer serialises `capacity` rather than `length`, the bytes at offsets 2000 to 5999 go out with B's response. They are A's. Notice the shape of it: the bug only appears when the new user writes **less** than the previous one. Every response larger than its predecessor is correct, which is why the defect survives testing, appears under a particular traffic mix, and is nearly impossible to reproduce from a single request. ## Two disciplines, and never a mixture | Discipline | Who clears | Strengths | Weakness | |---|---|---|---| | Clear on release | The returner, before the cell rejoins the free set | Free set holds only clean cells; data does not linger in an idle cell | The return path is on the hot path, and error paths must still clear | | Clear on acquire | The borrower, before first use | Cost is paid only by cells actually reused | Data sits in idle cells; every acquire site must remember | Either works. What fails is **splitting them**: half the code assumes the returner cleared, half assumes the borrower will, and the overlap ships stale bytes. Write the rule down at the pool's boundary and make one side own it — most designs pick clear on release, because a free set of clean cells is easier to reason about and idle cells then hold nothing sensitive. ## The near neighbours of this bug The same symptom has two other causes worth naming in an interview, because a good answer distinguishes them: - **Double return.** A cell is returned twice, so it appears on the free set twice and two borrowers are handed the same cell. Both write into it and each sees fragments of the other. The clearing discipline does not help here at all. - **Use after return.** The borrower returns the cell but keeps the reference and writes into it later. The new owner's data is overwritten mid-flight. Both produce interleaved data rather than a stale tail, and both point at ownership rather than clearing. Cheap defences: a flag on the cell recording whether it is currently lent out, checked on both acquire and release, kept in non-production builds if the check is too expensive to keep everywhere. ## What clearing costs, and how to avoid paying twice Wiping a whole cell on every return is real work proportional to capacity, and a pool of large buffers can spend a visible slice of its budget doing it. Three honest reductions: 1. **Clear only what was used.** Track the high-water offset the last borrower reached and wipe up to it, not to capacity. 2. **Reset the metadata always, the payload conditionally.** Length, position and flags are a handful of stores and must always be reset; the payload only has to be wiped if any consumer can read past the length. 3. **Make reading past the length impossible.** If every consumer is handed a view bounded by the length rather than the raw cell, stale tail bytes cannot be read even when they are present. This is the strongest fix, because it removes the class rather than the instance. The third is the answer that separates a strong candidate: clearing defends against the mistake, but bounding the view means the mistake has no effect.

  • Two borrowers report interleaved fragments of each other's output rather than a stale tail. What does that point at?
    Ownership, not clearing. Either the cell was returned twice and sits on the free set twice, so two borrowers hold it at once, or someone returned a cell and kept writing through the old reference. A lent-out flag checked on both acquire and release catches both, and no clearing discipline would have prevented either.
  • Wiping every cell on return is measurably expensive here. What do you cut first?
    Cut the payload wipe, never the metadata reset: length, position and flags are a few stores and must always be reset. Then wipe only up to the previous borrower's high-water offset rather than the capacity. Best of all, hand consumers a view bounded by the length, so tail bytes cannot be read even if they survive.
  • Why does this defect usually escape testing?
    Because it only appears when a borrower writes less than the previous borrower did, and only if something reads past the new length. A test suite with uniform payloads, or one that always grows, never triggers it — it needs a large response followed by a small one through the same recycled cell.

saying these in an interview costs you the question

  • Believes an acquired pool cell always arrives cleared for the borrower.
  • Resets the payload bytes but leaves the length or position field stale.
  • Splits clearing between acquire and release so each side assumes the other did it.
  • Blames the allocator or the runtime rather than the reuse discipline.
  • Thinks clearing on return would also prevent a cell being returned twice.
  • Treats the exposure as cosmetic rather than as one request reading another's data.