You're viewing the readable version of this site. The interactive extras (search, diagrams, read-aloud) need JavaScript and a current browser. Enable JavaScript; if it is already enabled, update your browser.

Notes · The Art of Debugging

budding

The Good, the Bad, and the Bugly

When browser bisection almost works—and why stopping can be the right result.

· · 28 min read

browsers, accessibility, csp, security, debugging, art-of-debugging, dissecting-systems

Program testing can be used to show the presence of bugs, but never to show their absence!

Edsger W. Dijkstra, Structured Programming, EWD 268, 1969

Cite this
APA
Mangalapilly, Y. J. (2026, July). The Good, the Bad, and the Bugly. Saṃhitā Notes. https://yesudeep.com/blog/the-good-the-bad-and-the-bugly/
BibTeX
@online{mangalapilly2026the,
  author  = {Yesudeep Jose Mangalapilly},
  title   = {The Good, the Bad, and the Bugly},
  journal = {Sa\d{m}hit\=a Notes},
  year    = {2026},
  month   = {July},
  url     = {https://yesudeep.com/blog/the-good-the-bad-and-the-bugly/},
  urldate = {2026-08-01},
}
Plain
Yesudeep Jose Mangalapilly. “The Good, the Bad, and the Bugly.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/the-good-the-bad-and-the-bugly/.
RIS
TY  - ELEC
AU  - Mangalapilly, Yesudeep Jose
TI  - The Good, the Bad, and the Bugly
T2  - Saṃhitā Notes
PY  - 2026
UR  - https://yesudeep.com/blog/the-good-the-bad-and-the-bugly/
Y2  - 2026-08-01
ER  - 

This is the first in The Art of Debugging, a series about turning failures into reliable evidence. It is the story of one keystroke crashing Chrome Canary and the twelve yes-or-no tests that should have searched 2,736 changes. Along the way, it turns the binary search many people met in a classroom into a practical debugging habit: find an ordered boundary, ask a reliable question in the middle, and keep the half that can still contain the answer. It also explains the awkward moment when the supposedly bad build worked. Then it follows the crash through a minidump and matching debug symbols into public Chromium source. The ending is unusual but important: the browser had already fixed the bug, so the website changed nothing. A final aside shows the same technique locating browser support boundaries for Content Security Policy.

The trigger was almost comically small. I pressed /. Instead of opening this website's keyboard command menu, Chrome Canary 153.0.7983.3 replaced the page with an “Aw, Snap!” screen and error code 11. Stable 151.0.7922.72 opened the window normally. Beta 152.0.7977.8 did too.

One keystroke, one renderer crash. Chrome Canary replaced the page with its “Aw, Snap!” screen after / tried to open the command window. The same action worked in Stable 151 and Beta 152.
One keystroke, one renderer crash. Chrome Canary replaced the page with its “Aw, Snap!” screen after / tried to open the command window. The same action worked in Stable 151 and Beta 152.

That is the failure. The healthy result is almost boring: a one-line command window appears over the page. If you are reading this on the site, press / now; Escape closes it.

The healthy result. Pressing / opens a one-line command window over the current page. Readers can try it here; Escape closes it.
The healthy result. Pressing / opens a one-line command window over the current page. Readers can try it here; Escape closes it.

The command window is a real modal dialog containing an editable combobox. Opening it changes what is drawn, moves focus, and updates the browser's hidden description of the page for assistive technology. At first, any of those layers could have been guilty: fixed positioning, backdrop blur, animation, focus, accessibility semantics, or some Canary-only setting.

My first job was not to guess. It was to make the crash boringly repeatable.

First, make the crash boring

Every browser build had to face the same small test. Load the same page in a fresh profile, press the same key once, and record whether the renderer lived. That repeatable yes-or-no test is what an algorithm calls an oracle:

For a fresh browser profile, load the page, press / once, and call the build bad if the renderer crashes. Otherwise call it good.

Oracle — a repeatable test that gives an algorithm an answer it can act on. In regression bisection that answer is usually good, bad, or unknown. Learn more.

Fresh profiles removed extensions and accumulated browser state. Turning off GPU acceleration did not help. Neither did disabling field trials, variation-seed fetching, or component updates. I reproduced the crash three times; every dump showed the same renderer thread, fault address, and 36-frame unnamed stack.

None of this named the bug. It did something more modest and more useful: it made the competing explanations pay rent. The GPU and ordinary variation state no longer explained what I was seeing.

One bit, carefully earned. The reproduction recipe collapses a complicated browser run to good or bad. Controls remove rival explanations without changing the question the search asks.

Imagine testing a row of light switches in a dark building. Before searching for the broken switch, make sure “the light came on” means the same thing every time. Use the same room, the same bulb, and the same motion. Otherwise a dark room might mean a dead bulb, a power cut, or simply that somebody tested a different room.

Ask the build in the middle

The known-good Beta build came from Chromium main-line position 1669021. The bad Canary came from 1671751. Between them sat 2,736 changes and one change, not yet identified, that turned good into bad. Reading every patch would turn a one-key crash into a cataloging project.

Binary search asks a cheaper question: what does the build in the middle do?

If the midpoint works, every older candidate can go. If it crashes, every newer candidate can go. One honest answer dismisses half the suspects. Starting with nn candidates, the list shrinks like this:

If that sounds unlike the binary search from a data-structures class, look past the nouns. There is no visible array and no number to look up, but the shape of the reasoning is the same:

In the textbook In this investigation
sorted array browser builds in revision order
target value the first build that crashes
middle element the browser build at the midpoint
comparison load the page and press /
lower and upper indexes the last good and first bad revisions
keep the possible half keep the half containing the transition

You have probably used binary search without calling it binary search. Any time you ask “when did this start?” and jump halfway through an ordered history, you are doing the important part. The array is merely one place the pattern fits.

The essential promise is that the answers change direction only once. Read from oldest to newest, they must look like this:

good, good, good, ..., bad, bad, bad

The algorithm is not searching for “bad” in general. It is searching for the boundary between the last good candidate and the first bad one. If the same bug disappears and returns across the interval, or if the test changes its mind, a single boundary no longer describes the evidence.

Locate the first bad browser build.

FIRST-BAD-BUILD(B, ORACLE)
Input:  ordered builds B[1:n], reliable ORACLE
Output: least i with ORACLE(B[i]) = BAD, or NIL

low  1
high  n
answer  NIL
while low  high
    mid  low + ⌊(high - low) / 2
    if ORACLE(B[mid]) = BAD
        answer  mid
        high  mid - 1
    else
        low  mid + 1
return answer

The procedure records a bad midpoint before searching the older half because that midpoint may be the boundary. Its contract also exposes the assumption the investigation is about to violate: ORACLE must give reliable answers whose GOOD results all precede its BAD results.

n, n2, n4, ,1

The worst case is the base-two logarithm of nn, rounded up. Before compressing that sentence into symbols, here is the whole refresher it needs:

Math refresher—read the symbols from the inside out.

  • Base two means that each step divides by two. Changing the base changes the step: base ten would count divisions by ten.
  • A logarithm counts those steps. If 2k=n2^k = n, then log2n=k\log_2 n = k: doubling one kk times makes nn, and halving nn the same number of times returns to one. When nn is not an exact power of two, the answer falls between two whole numbers.
  • The ceiling x\lceil x \rceil is the smallest whole number greater than or equal to xx—in ordinary language, round upward. A test cannot be performed 0.42 times, so the fractional last step still costs one complete test.
  • Worst case means the largest number of tests the algorithm may need, over every possible location of the good-to-bad boundary.

So log2273611.42\log_2 2736 \approx 11.42, and its ceiling is 1212. Equivalently, 211<27362122^{11} < 2736 \le 2^{12}. OpenStax explains logarithms as inverse functions; MathWorld defines the ceiling function.

The sentence now has a compact mathematical expression:

log2n \lceil \log_2 n \rceil

For this interval, substitute the number of candidates:

log22736=11.42=12 \lceil \log_2 2736 \rceil = \lceil 11.42\ldots \rceil = 12

In plain terms, count how many times the remaining list can be halved before one candidate remains. Eleven halvings cover only 2,048 candidates; twelve cover up to 4,096. So twelve reliable answers are enough to reduce 2,736 changes to one.

A logarithm counts repeated division. Following the candidate counts takes twelve halvings to reach one; the lower comparison shows why 11.42 must be rounded upward rather than down.

Twelve is not just a clever upper bound. It is the best possible worst case for a reliable yes-or-no test. After one answer there are at most two distinguishable outcomes; after two, four; after kk, at most 2k2^k. To distinguish among nn possible boundaries, any method therefore needs enough answers that 2kn2^k \ge n. In other words, kk must be at least the base-two logarithm of nn, rounded up. Binary search meets that lower bound by keeping the two remaining halves as even as possible.

That analysis counts decisions, not minutes. Downloading a browser, compiling a commit, or repeating a flaky test can dominate the clock. The algorithm spends the expensive resource carefully; it cannot make each experiment cheap.

Twelve decisions replace 2,736 inspections. Each answer discards the half that cannot contain the good-to-bad boundary. The geometry, not browser knowledge, buys the speedup.
Prediction checkpoint. Guess how many times 2,736 can be halved before one candidate remains.

Even the midpoint can lie

The idea is simple enough to fit on a napkin. Correct implementations have still failed in production. In 2006, Joshua Bloch reported that the binary search published with a correctness argument in Programming Pearls could compute the wrong midpoint on a sufficiently large array. The implementation he wrote for Java's standard library carried the same defect. The expression low + high could overflow before the program divided the result by two. His account reaches farther back: binary search was first published in 1946, but a published version correct for every input size did not appear until 1962. It also records a second sting: one proposed C repair still relied on behavior the C standard did not guarantee.

The usual repair computes the distance first—low + (high - low) / 2—under the appropriate integer rules. The deeper lesson is not to memorize that spelling. The proof used mathematical integers; the program used fixed-width machine integers. The browser investigation will soon hit the same kind of crack between a clean model and the world: the search assumes comparable builds, while two builds with the same printed version can differ. In both cases, binary search is sound and a hidden premise is not.

Chromium publishes the bisect-builds.py tool for exactly this job. Since March 2025 it has also published Chrome for Testing builds at roughly per-commit granularity—generally every commit on macOS. With -cft, the tool chooses a midpoint, downloads it, and opens it. A person or automated test supplies the one bit it cannot know: good or bad.

From a Chromium checkout, the command for this investigation was:

python3 tools/bisect-builds.py -cft -a mac-arm \
  -g 1669021 -b 1671751 --verify-range

The quiet hero is --verify-range. Before trying a midpoint, it opens both ends and checks that the good build really works and the bad build really fails. For an automated reproduction, --command can run a browser test and classify its exit code; --chromedriver fetches the matching driver. A person pressing one key is also a perfectly good oracle when a script cannot reliably observe the browser process.

Regression bisection is binary search over history, but first you must prove that “good” and “bad” mean the same thing at both ends.

Note

The version-control form: git bisect. What git log --oneline --graph draws as commit history is the candidate set, the first commit with a changed behavior is the target, and a test is the oracle. A manual run starts with git bisect start, marks known endpoints with git bisect good and git bisect bad, then tests each commit Git selects. If a command can classify the checkout by its exit status, git bisect run TEST_COMMAND automates the conversation. Git's documentation also provides git bisect skip for a commit that cannot be tested—but skipped commits near the boundary can leave several possible culprits. Real history is a graph rather than an array, so Git chooses commits that divide the remaining reachable history well; the same halving intuition survives without pretending the topology is perfectly linear.

Then the bad build worked

Before choosing a midpoint, the tool opened the Chrome for Testing build at the supposedly bad position, 1671751. I loaded the page and pressed /.

The command window opened.

I tried a standalone Chrome for Testing build with the exact same 153.0.7983.3 version string as the crashing Canary. That worked too. The tool stopped before the first midpoint because there was nothing honest to bisect yet.

It was tempting to call the endpoint bad anyway and keep going. That would have made the script look busy while stripping the reasoning out of binary search. If “bad” passes, the next midpoint tells you nothing about which half to discard.

The contradiction exposed a hidden assumption: a version number is not the whole browser. Behavior also depends on the exact artifact, runtime state, and machine:

behavior=f(source revision,build artifact,runtime state,machine). \text{behavior} = f(\text{source revision},\; \text{build artifact},\; \text{runtime state},\; \text{machine}) .

This is the browser-build form of the same lossiness described in You Don't Want Separate Repos: SemVer is a lossy hash of a diff the VCS already had. A browser version string compresses even more—the source, toolchain, build flags, packaging, and experiments. A commit hash identifies the source more precisely, but it still names only one input to the function above. A content hash can identify the exact artifact; reproducing that artifact requires the complete set of build inputs.

The main-line position orders only the source revision. It does not hold branded packaging, generated configuration, field trials, branch cherry-picks, or the machine constant. Two browsers can print 153.0.7983.3 and still contain meaningfully different experiments.

The label matched; the contents did not. Source history can be searched only while the browser artifact, runtime state, and machine stay fixed. The two 153.0.7983.3 builds landed in different cells.
Diagnosis checkpoint. The endpoint contradiction decides what may happen next.

Binary search did not fail. It caught us asking it a question whose premises were false.

The crash left another clue

Bisection had reached a dead end, but each crash had left a note behind. Chrome's Crashpad directory contained three minidumps: compact snapshots of the renderer at the moment it died. Mozilla's Rust minidump tools found the same signature in all three—an EXC_BAD_ACCESS at address 0x2b on the renderer's main thread, with the same unnamed Chrome Framework stack.

Minidump — a small crash snapshot containing enough process state to rebuild a call stack without saving a full core dump. It can include fragments of process memory, so publish the resulting stack—not the raw dump. Learn more.

A dSYM is a source map for native code. The crash dump gives you machine addresses; the dSYM translates them back into functions, files, and lines. Official macOS Chrome builds keep that debug information outside the shipped binary. Chromium's macOS debugging guide explains the arrangement, and its public tools/mac/download_symbols.py script fetches the symbols.

python3 tools/mac/download_symbols.py \
  --version 153.0.7983.3 --channel canary --arch arm64 \
  --out /path/to/symbols

dwarfdump --uuid "/path/to/Google Chrome Framework"
dwarfdump --uuid "/path/to/Google Chrome Framework.dSYM"
minidump-stackwalk crash.dmp > raw-stack.txt

There is one catch: the source map must belong to the exact binary that crashed. The binary and dSYM carry matching UUIDs. If those values differ, addresses may still turn into convincing function names—just not the right ones.

dSYM — debug information that maps optimized native-code addresses back to functions, files, and lines.

UUID — a 128-bit identifier written as groups of hexadecimal digits. Here it acts like a build-identity tag: the binary and its dSYM must carry the same UUID before the addresses can be translated safely. Learn more.

The downloaded dSYM and the installed Canary framework both carried UUID 4C4C4440-5555-3144-A11B-2D13053E4AC4. With that identity check satisfied, the anonymous addresses became this stack:

blink::AXObject::AncestorMenuList()
blink::AXObjectCacheImpl::GetActiveAriaModalDialog()
blink::AXObject::IsBlockedByAriaModalDialog()
blink::AXObject::ComputeIsInertViaStyle()
blink::AXObject::UpdateCachedAttributeValuesIfNeeded()
blink::AXNodeObject::InsertChild()
Symbol identity turns an address into an explanation. The dump says where the processor stopped; the exact dSYM translates that address into the Blink accessibility stack; the source supplies the invariant that was violated.

The stack overturned the visual hypothesis. The renderer was not dying in CSS animation or GPU composition. It was inside Blink's accessibility-tree code, inserting children beneath a modal dialog.

Public Chromium history supplied the next clue. Change 018ca90a, at position 1671058, had added checks to AXObjectCacheImpl::GetActiveAriaModalDialog(). The method looked like a simple question—“which modal dialog is active?”—but answering it called IsVisible(). That call tried to walk the accessibility tree while the first walk was still building it.

Imagine rewriting a seating chart. Halfway through, you stop to check whether everyone on the chart can see the stage. That check starts reading the same chart from the top, reaches a seat you have not filled in yet, and falls apart. Finish the rewrite before asking questions about the finished chart. The chart is the accessibility tree; the second read is the re-entry.

The next day, change ce2a6293, at position 1671982, fixed the problem in third_party/blink/renderer/modules/accessibility/ax_object_cache_impl.cc:

Element* AXObjectCacheImpl::GetActiveAriaModalDialog() const {
  return active_aria_modal_dialog_;
}

The fix made the getter a getter again. Validity maintenance moved into the document lifecycle, where tree changes belong, instead of letting a question start another walk through a half-built tree. The first tagged branch containing the fix was 153.0.7984.0.

Why we changed nothing

The website could have dodged the crash by removing its modal-dialog semantics. That would also have made the page less accurate to assistive technology. A temporary Canary bug would have left a permanent scar on the website.

We changed nothing.

The page was using a valid platform contract. The crash belonged to Blink's accessibility cache. Chromium had already merged the repair, and the next Canary branch contained it. The smallest correct fix was the one upstream had already made.

Repair the layer that owns the invariant. Removing dialog semantics would route around the crash by weakening the website. The upstream change preserves the contract and repairs accessibility-tree lifecycle instead.

Important

A workaround is not free because it is short. It becomes another behavior to test, explain, remember, and eventually remove. When the upstream owner has already fixed a pre-release defect, leaving valid application code alone can be the lower-risk choice.

That is not a rule to wait blindly. A production crash affecting stable users, no deployable browser fix, or invalid application markup would change the decision. A narrow, removable compatibility shim can be responsible. The useful questions are who is affected, what correctness the workaround costs, and how soon the real fix arrives—not how quickly the workaround can be typed.

The same trick works elsewhere

The useful abstraction is not “look something up in an array.” It is “find the place where an ordered yes-or-no answer changes.” That includes the first release that supports an API, the first commit that fails a test, the smallest buffer that avoids a failure, the largest batch size that stays below a latency budget, or the earliest date on which a report changes. The question must remain stable and change only once across the chosen interval. If larger batches sometimes recover after failing, for example, there is no single threshold to bisect.

This was not my first browser bisection. I had used the same method to find the versions of Safari and Firefox where a Content Security Policy behavior changed. The symptom was different; the search was the same.

Under a CSP Level 3 'strict-dynamic' policy, WebKit blocked an external script even though the script's hash matched. The one-bit test became: does this fixed policy execute this integrity-matched script?

CSP hash source — a script-src expression that authorizes exact script bytes with a cryptographic digest. For an external script, CSP Level 3 pairs that digest with Subresource Integrity. Learn more.

The public record includes WebKit bug 270784, the reproduction added to Web Platform Tests, the standards discussion in issue 653, and the live cross-browser results. Bisection located the compatibility boundaries: Firefox before 119 and Safari before 18.2 needed a nonce fallback instead of a hash-rooted policy.

Note

Why this fallback may need browser detection. Ordinary feature detection runs JavaScript and inspects the result. CSP must decide whether the first JavaScript may run, so that test comes too late. The server may need a coarse engine/version classification to choose the hash or nonce policy. Keep that fact non-identifying: do not persist it, combine it with other signals, or keep the branch after the affected versions age out.

One policy, two compatibility boundaries. The hash-rooted strict-dynamic specimen selected a nonce fallback before Firefox 119 and Safari 18.2. The fallback follows measured engine behavior, not a broad guess about browser families.

The recipe was unchanged:

  1. Write one small standards-level test.
  2. Pin browser builds instead of trusting auto-update.
  3. Find one version that passes and one that fails.
  4. Test the middle until the two versions are adjacent.
  5. Read the landing change and issue discussion before choosing a fallback.

Chrome for Testing exists because auto-update is good for users and awkward for reproducible experiments. Firefox and Safari use different archives, but binary search does not care. Give it comparable builds in order and one reliable question.

A crash regression and a support boundary can demand different product choices. One may justify a browser revert; the other may require a standards-compatible fallback. Bisection finds the boundary. It does not make that policy decision for you.

When the recipe gets stuck

Most failed bisections are failed experiments, not failed binary searches. These are the questions I would check first:

  • I do not have a Chromium checkout. You do not need the whole source tree to begin. Chromium's bisection guide shows how to download the standalone script on macOS, Linux, or Windows and explains the supported platform names.
  • The tool cannot find enough builds. Check chrome://version and make sure the endpoints are versions or main-line commit positions—not branch numbers. The same range guide explains the accepted forms and an important limit: trunk bisection cannot discover a change merged only into a release branch.
  • The same build sometimes passes and sometimes fails. Do not force a flaky result into good or bad. The current script source documents -t / --times for repeating each build. If repetition does not produce a stable answer, narrow the test or treat the result as unknown before resuming the search.
  • Installed Chrome and the downloaded build disagree. First compare the same artifact family. Chromium's guide notes that non-branded builds use a field- trial testing configuration. When that difference matters, pass the browser switch --disable-field-trial-config after the script's -- separator. This is exactly the class of mismatch --verify-range is meant to reveal.
  • Browser automation cannot find a compatible driver. Use --chromedriver and the %d placeholder when the bisection tool can supply it. For manual selection, Chrome's version-selection guide explains how Chrome and ChromeDriver releases match, including the Chrome for Testing JSON endpoints.
  • There is no dump, or the dump has only addresses. Start with Chromium's crash-report guide for the local Crashpad layout. Then follow Apple's symbolication guide: compare the binary, crash report, and dSYM UUIDs with dwarfdump before trusting any resolved frame.
  • LLDB refuses to attach to official Chrome. That can be expected: official builds are code-signed to restrict debuggers. Read Chromium's macOS debugging guide before changing code signing or System Integrity Protection. Symbolicating an existing dump does not itself require weakening those protections.

Addendum: writing the search you just proved correct

Bisecting by hand needs no code. You read a revision range, pick a middle, and press a key. The reasoning is the whole method, and it cannot overflow.

Writing the same reasoning down is a different act. The proof used mathematical integers, an array that is merely long, and a comparison that always answers. The program gets fixed-width machine integers, a real allocator, and a comparator someone else wrote. Four cracks open between the two. Bloch's is the famous one; the other three are quieter and, in a corpus that keeps growing, more likely.

One line, five languages, five different failures

The received advice is to compute the distance first—low + (high - low) / 2 instead of (low + high) / 2. That is good advice, but it is advice about a language, not about arithmetic. The same expression fails differently depending on where you write it, and in one of these languages the received diagnosis is simply wrong.

int mid_unsafe(int low, int high) { return (low + high) / 2; }
int mid_safe(int low, int high)   { return low + (high - low) / 2; }

C. Signed overflow is undefined behavior, so the compiler is entitled to assume it cannot happen.

func midUnsafe(low, high int32) int32 { return (low + high) / 2 }
func midSafe(low, high int32) int32   { return low + (high-low)/2 }

Go. Signed overflow is defined, and wraps. The program keeps running with a negative index.

fn mid_unsafe(low: i32, high: i32) -> i32 { (low + high) / 2 }
fn mid_safe(low: i32, high: i32) -> i32 { low + (high - low) / 2 }

Rust. Debug builds panic on overflow; release builds wrap. The same binary search can pass its tests and ship the defect.

def mid_unsafe(low, high): return (low + high) // 2
def mid_safe(low, high):   return low + (high - low) // 2

Python. Integers are arbitrary precision, so this particular crack never opens.

const midFloat = (low: number, high: number): number =>
  Math.floor((low + high) / 2); // safe: never coerces
const midShift = (low: number, high: number): number =>
  (low + high) >>> 1; // unsafe: coerces the SUM through ToUint32
const midSafe = (low: number, high: number): number =>
  low + ((high - low) >>> 1);

TypeScript. Numbers are doubles, so the sum is exact and the divided form is safe. The shifted form is not, and the shift is what a performance pass reaches for.

Run each at low=230\text{low} = 2^{30}, high=230+2\text{high} = 2^{30} + 2, where the true midpoint is 230+1=1,073,741,8252^{30} + 1 = 1{,}073{,}741{,}825:

Language Result Mechanism
C (clang, -O2) 1,073,741,823-1{,}073{,}741{,}823 undefined behavior; sanitizer confirms
Go 1,073,741,823-1{,}073{,}741{,}823 defined wrap to a negative index
Rust (debug) panic, exit 101 overflow check fires
Rust (release) 1,073,741,823-1{,}073{,}741{,}823 wraps silently
Python 1,073,741,8251{,}073{,}741{,}825 arbitrary precision; correct
TypeScript (divided) 1,073,741,8251{,}073{,}741{,}825 doubles are exact here; correct
TypeScript (shifted) 1,073,741,823-1{,}073{,}741{,}823 >> coerces through ToInt32

Notice that C, Go, and release-mode Rust agree on a wrong answer for three different reasons, and that Rust's debug build reports a defect its own release build would have hidden.

Note

JavaScript reaches this bug by a different road, and the usual diagnosis misses it. Numbers are doubles, so low + high is exact well past any array length; the arithmetic never overflows. What truncates is the coercion the bitwise operators perform on their operands: an unsigned shift takes the sum modulo 2322^{32}, a signed shift modulo 2312^{31}—and then reads the result as negative. So the plain Math.floor((low + high) / 2) is the safe spelling, and the trap only appears when someone replaces the division with a shift to make the loop faster. The defect is attached to the optimization, not to the expression it replaced. That is a bad place for a defect to live, because it arrives in a change whose stated purpose is that nothing observable should change.

A loop that never ends

The second crack has nothing to do with integer width. A binary search carries an invariant—which half is still possible—and each iteration must both preserve it and shrink the interval. Mismatch the loop condition and the update, and the interval stops shrinking:

while (low < high) {
  const mid = low + ((high - low) >>> 1);
  if (predicate(mid)) high = mid;
  else low = mid; // never terminates: with high - low === 1, mid === low
}

The interval stops shrinking when the low bound is assigned the midpoint it just tested.

When the interval narrows to two candidates the midpoint is the low bound, so low = mid assigns it to itself and the loop spins forever. The repair is low = mid + 1: the midpoint has been tested, so it is no longer a candidate. The general rule is that every branch must exclude the element it just examined, because a search that cannot discard the thing it tested is not making progress. This is the same discipline as git bisect skip—an untestable commit stays in the candidate set, and the interval stops shrinking for exactly the same reason.

The first match, not a match

The third crack is the one this investigation actually depends on, and it is not usually taught as a bug at all.

Textbook binary search answers is this value present, and where. Bisection asks something strictly harder: where is the boundary. A build that crashes is not the answer; the first build that crashes is. An array of good, good, bad, bad, bad has three matches for "bad" and only one of them is the regression. A search that returns any match returns a real crash that is not the culprit—and every subsequent hour of debugging is spent on a change that was already broken when it landed.

// Returns the FIRST index where the predicate holds, or `high` if none does.
// The invariant is that everything below `low` fails and everything at or
// above `high` is unknown — so the answer is `low` when they meet.
function firstWhere(
  low: number,
  high: number,
  predicate: (at: number) => boolean
): number {
  while (low < high) {
    const mid = low + ((high - low) >>> 1);
    if (predicate(mid)) high = mid; // mid may itself be the boundary: keep it
    else low = mid + 1; // mid fails, so the boundary is strictly above it
  }
  return low;
}

Membership answers "is it here". A boundary search answers "where does it start", which is the question bisection asks.

The asymmetry between the two branches is the whole point, and it is why the loop above cannot be written with low < high=. A matching midpoint is not discarded, because it may be the boundary; a failing one is, because it cannot be.

A comparator the array does not share

The last crack is a premise, not a line of code. Binary search discards half the array on the strength of one comparison, so it requires that the array is ordered by the same comparison the search performs. Two orderings that agree on every example you tried are not the same ordering.

The failure is unusually easy to reach with text. Sort a German word list with Intl.Collator('de') and it reads angstrom, Ångström, Apfel, äpfel, apple — each accented word beside its plain twin, which is what a reader expects. Sort the same list with < and it reads Apfel, Zürich, angstrom, apple, …, Ångström, äpfel, with the accented words exiled to the end. A binary search built on one order and queried with the other still returns an index; it is simply the wrong one, and only for the words that carry accents.

The same crack opens without a collator at all. JavaScript compares strings by UTF-16 code unit, which is not code point order: a character outside the Basic Multilingual Plane is stored as a surrogate pair from U+D800..U+DFFF, so it sorts below ordinary characters with much larger code points. By code unit, U+1F600 precedes U+FF21; by code point it follows it. Emoji and rare CJK sort into a different place than the numbers say they should.

Warning

This premise fails silently, and it fails in the direction of a wrong answer rather than a crash. An array ordered by one comparator and searched by another still returns an index. It is simply the wrong one, on the inputs where the two orderings diverge—which, for a search index spanning several languages, is precisely the content that is hardest to test and easiest to ship. If a structure is built by one comparison and queried by another, the two must be the same function, and the cheapest way to guarantee that is to have only one.

This is the same shape as the failure that stopped the bisection in this investigation. There, two builds printed the same version and were not the same artifact. Here, two orderings print the same result on every ASCII example and are not the same order. In both cases the search is sound and a hidden premise is not.

What we changed after writing this

Every one of these is old and documented. We reintroduced the first one anyway, in our own search engine, while making a prefix lookup faster—replacing a division with a shift and quietly restructuring a safe expression into the classic unsafe one. It survived a full test suite, because the vocabulary it searches has a few thousand terms and the defect needs two billion.

Important

A defect that no reachable input can trigger will not be caught by tests, so it has to be caught by reading. Ours now is: the correct midpoint is a single shared function, and a repository check greps for the unsafe shape and fails the build. The check is a ratchet rather than a clean bill of health—it carries the sites that predate it and refuses to let the list grow, and it also fails when a listed site is repaired but left on the list, so the inventory cannot rot. The first thing it caught was the prose in this article quoting the very pattern it forbids, which is its own small lesson: a guard that cannot tell a warning from an occurrence will forbid you from documenting the thing it guards.

Lessons

  • First make the failure repeatable enough to call each build good, bad, or unknown.
  • Binary search finds one change among nn ordered candidates by repeatedly halving the remaining interval. It is a reusable shape of reasoning, not a trick that belongs only to arrays.
  • A reliable yes-or-no answer carries at most one bit. That makes the base-two logarithm of nn, rounded up, both sufficient and necessary in the worst case; for 2,736 candidates, the bound is twelve decisions.
  • The clean mathematics still depends on machine and experimental premises: safe midpoint arithmetic, comparable candidates, a stable oracle, and one ordered transition.
  • Test both ends before the middle. If the bad build passes, stop and find out what differs between the artifacts or environments.
  • Treat a version string as a label, not a complete identity.
  • Match a dSYM's UUID to the crashing binary before trusting the names in a stack.
  • Let the evidence overturn the first theory. This visual crash came from the accessibility tree, not graphics.
  • Debugging ends with an ownership decision. An upstream fix can make “change nothing downstream” the safest result.
  • The same bisection method finds crashes, slowdowns, rendering changes, and browser support boundaries.
  • Writing the search down adds cracks the reasoning does not have: a midpoint that wraps, a loop that never shrinks, a match that is not the boundary, and an ordering the array does not share. Three of the four fail silently.

Practice

Retrieval. Recover the three premises that make a regression bisection valid.
Discrimination. Separate symbol identity from a merely plausible stack.
Transfer. Decide whether a compatibility workaround belongs downstream.

References

  1. Edsger W. Dijkstra. “Structured Programming (EWD 268).” 1969. — the epigraph's primary source and the larger argument about what tests can establish
  2. Joshua Bloch. “Nearly All Binary Searches and Mergesorts are Broken.” Google Research, 2006. — the midpoint-overflow bug and the gap between mathematical integers and machine arithmetic
  3. git-bisect documentation.” Git. — manual classification, automated test commands, custom terms, skipped commits, and finding the first change in a commit graph
  4. Chrome for Testing: per-commit builds.” Chromium, 2025. — the announcement of per-commit Chrome for Testing builds
  5. Bisecting Chromium builds.” Chromium. — the public regression-bisection guide
  6. bisect-builds.py.” Chromium. — the public regression-bisection implementation
  7. Chrome for Testing: reliable downloads for browser automation.” Chrome Developers. — why pinned, non-auto-updating browser artifacts exist
  8. ChromeDriver version selection.” Chrome Developers. — matching an automated test driver to an exact Chrome or Chrome for Testing build
  9. Debugging Chromium on macOS.” Chromium. — LLDB, multiprocess debugging, official dSYMs, and symbolized traces
  10. Apple. “Adding identifiable symbol names to a crash report.” — checking binary and dSYM UUIDs before resolving addresses
  11. Chromium change 018ca90a.” Chromium, 2026. — the change that introduced the accessibility-tree regression
  12. Chromium change ce2a6293.” Chromium, 2026. — the source-level repair
  13. Mozilla. “Rust Minidump.” — Mozilla's cross-platform parser, processor, and stack walker
  14. W3C. “Content Security Policy Level 3: strict-dynamic.” — the standard's trust-propagation model
  15. WebKit bug 270784.” WebKit. — the external-hash and strict-dynamic interoperability defect
  16. WPT pull request 44769.” Web Platform Tests. — the cross-browser regression test
  17. strict-dynamic hash-source results.” WPT. — live cross-browser results for the compatibility boundary
  18. Mozilla bug 1409200.” Mozilla. — a related historical boundary between strict-dynamic and external-script hashes
  19. ECMAScript: ToUint32 and ToInt32.” Ecma International. — why a JavaScript bitwise operator truncates its operand even though the arithmetic itself is exact
  20. Arithmetic operators: overflow.” cppreference. — signed integer overflow as undefined behavior in C
  21. The Go Programming Language Specification: integer overflow.” Go. — Go's defined wrapping behavior for signed integers
  22. The Rust Reference: overflow.” Rust. — panic on overflow in debug builds and two's complement wrapping in release
  23. Numeric types.” Python. — arbitrary-precision integers, which close the overflow crack entirely
  24. Jay Abramson. “Logarithmic Functions.” OpenStax Precalculus 2e, 2021. — logarithms as the inverses of exponential functions
  25. Eric W. Weisstein. “Ceiling Function.” MathWorld. — the least integer greater than or equal to a real number

How to cite

APA
Mangalapilly, Y. J. (2026, July). The Good, the Bad, and the Bugly. Saṃhitā Notes. https://yesudeep.com/blog/the-good-the-bad-and-the-bugly/
BibTeX
@online{mangalapilly2026the,
  author  = {Yesudeep Jose Mangalapilly},
  title   = {The Good, the Bad, and the Bugly},
  journal = {Sa\d{m}hit\=a Notes},
  year    = {2026},
  month   = {July},
  url     = {https://yesudeep.com/blog/the-good-the-bad-and-the-bugly/},
  urldate = {2026-08-01},
}
Plain
Yesudeep Jose Mangalapilly. “The Good, the Bad, and the Bugly.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/the-good-the-bad-and-the-bugly/.
RIS
TY  - ELEC
AU  - Mangalapilly, Yesudeep Jose
TI  - The Good, the Bad, and the Bugly
T2  - Saṃhitā Notes
PY  - 2026
UR  - https://yesudeep.com/blog/the-good-the-bad-and-the-bugly/
Y2  - 2026-08-01
ER  - 

Annotations

Thank you — your note is held for review and will appear once approved.

Thank you — your note is published.

Please sign in below to leave a note.