Last weekend, Visual Pinball on Android stopped loading pinball tables out of nowhere. Not all of them. Only the tables whose VBScript source contained a comment with an emoji somewhere in it. After the September Android update, the game's script engine reported "Invalid character" on files it had parsed happily for months.
That symptom sounds absurd. The root cause is a one-branch bug in Android's C library, and the story of how it got reported is a small wake-up call for anyone who still believes "open source" means "you can check what is running on your device."
The bug: two functions, one wrong branch
On October 12, a developer opened issue #2260 on the Android NDK repository. The title is dry, but the content is a masterclass in bug reporting.
On Android 17 builds from September 2026, two standard C functions stop working correctly: wcscspn() and wcspbrk(). These are not exotic APIs. They have been in the C standard for over thirty years. They scan a wide string for the first character that is, or is not, a member of a small set. A JSON parser looking for the next delimiter, a lexer looking for the end of a line, a template engine looking for a wildcard, they all reach for these functions.
Here is what breaks, in plain terms:
-
The trigger. If the search set has two or more characters and all of them are below U+0100 (so: plain ASCII, or Latin-1), then
wcscspn()stops at the first character of the string at or above U+0100 and reports it as a match. Every emoji, every CJK character, every Cyrillic, Greek, or extended-Latin letter becomes a false stop. - The scope. The reporter swept every code point from U+0001 to U+10FFFF. The function returns a wrong answer for 1,111,808 of them. Only U+0001 through U+00FF behave correctly.
-
What still works.
wcsspn(),wcschr(),wcsrchr(),wmemchr(), andwcsstr()are all correct. The bug is precise: only the two "complement" functions that stop at a non-member are wrong.
The asymmetry is the best clue in the whole report. wcsspn() keeps scanning while characters are in the set, so stopping at a character outside the set is the correct behavior for it. wcscspn() must do the opposite: skip over characters that are not in the set. Somewhere in an optimized rewrite, a branch got shared between the two, and the skip logic was lost.
That explains the pinball tables. The VBScript lexer calls wcspbrk(ptr, L"\n\r") to find the end of a comment line. Your string hits an emoji in the comment first. The function reports the emoji as the end of the line. The parser reads half a comment as code and rejects the whole table.
Why nobody could just read the source
Here is the part that turned a routine libc bug into the top story on Hacker News, where it collected more than 430 points in a day.
The reporter did the natural thing first: check the source. The public android17-release sources, published in June 2026 as android-17.0.0_r1, build wcscspn() and wcspbrk() from the LLVM libc import, and that code is a plain nested loop. It is correct. If you compiled the public source, you would not get the bug.
The September builds on a Pixel 7 and a Pixel 8a are different code. We know that because the reporter said so in the most 2026 way possible: by disassembling libc.so straight off the phone and reading the shipped machine code.
The reconstructed mechanism looks like this:
a2564: ldrb w8, [x9, x8] ; look up the byte in a 256-entry set table
a2568: tbnz w8, #0, a2578 ; bit set -> in the set, stop (correct)
a256c: ldr w8, [x22, #0x4]! ; load next character
a2570: cmp w8, #0x100
a2574: b.lo a2564 ; below 0x100 -> keep scanning
a2578: ... ; 0x100 or above -> STOP, wrongly counted as a match
The optimized path uses a lookup table for the set, then loops only while the current character is below 0x100. The moment a character at or above 0x100 appears, the loop exits and the caller treats the position as a match. For wcsspn() that exit is correct. For wcscspn() it is exactly backwards. The set-membership check and the "keep going" condition got fused into one exit condition, and the complement functions inherited the wrong one.
So the sequence of events was: a real regression shipped to production phones in September, the public source for Android 17 predated the regression by months, and the only way to give Google's engineers something actionable was to read the binary.
Google's response says a lot, in both directions
To Google's credit, the response was fast and human. Elliott Hughes, an engineer on the bionic team, showed up in the issue within hours of it being filed. His first comment is the one every developer who has maintained a mature codebase will feel in their bones:
"gah, even more embarrassing is that we do have an existing test for wcscspn(), it's just wrong"
The existing test itself asserted the incorrect behavior. It expected wcscspn(L"he\ufffelo", L"lo") to return 2, when the correct answer is 3, because U+FFFE is not in the set {l, o}. The test suite contained a bug that mirrored the product bug, which is why the regression never tripped an alarm. His suggested one-line test fix is beautiful for how small it is: assert that wcscspn(L"aĀb\n", L"x\n") returns 3, and the emoji-shaped hole in the test coverage closes.
That is a genuinely good sign. The team engaged on the merits, immediately, in public, with a reproducer-grade diagnosis of why the tests missed it.
The uncomfortable part is everything around that response. Google's own documentation now states that effective 2026, source code is published to AOSP only twice a year, in Q2 and Q4, "to align with our trunk stable development model." The android-latest-release branch that contributors are pointed to was still too old to contain the relevant changes when this bug was filed. During the HN discussion, GrapheneOS developers pointed out that Android 17 is the first release since Android 3.x to add new APIs without a corresponding AOSP release.
In other words: device binaries move monthly, source dumps move half-yearly, and the gap between them is now wide enough for real regressions to live inside it, unseeable, until someone with a disassembler and a lot of patience comes along.
The Hacker News debate: why is the source late?
The comment thread on the story is worth your time, with one caveat: the most popular theory in it is speculation, not fact.
The top-voted commenter argued that a recent EU regulation mandating update timelines for devices may have backfired. The reasoning goes: if shipping source publicly counts as "releasing" an update and starts a compliance clock, Google has an incentive to delay the public source drop until partners like Samsung have finished their own builds. It is a coherent theory. It is also, as the commenter himself admitted, unprovable from the outside. Google has not commented on its reasoning beyond the trunk-stable explanation in the docs. Treat the EU theory as a plausible hypothesis, not a finding.
What is not speculation is the structural fact everyone in the thread agrees on: the delay is now long enough to matter. When the source lag was days, a reporter could check the repo. At months, the repo checks out clean while the phone misbehaves, and the debugging workflow inverts: binary first, source later, maybe never for the build you are actually running.
A checklist for reporting bugs in binaries that do not match public source
This is the part I would save, because the workflow generalizes far beyond Android. Closed-ish binaries inside open-source projects are becoming more common, and sooner or later you will hit a library where the shipped artifact and the public repo disagree. The issue author's playbook, cleaned up and generalized:
- Isolate with a minimal reproducer first. Before any disassembly, build the smallest program that shows the wrong answer and the expected answer side by side. The NDK issue's reproducer is a dozen lines of C. It makes every later claim checkable.
- Sweep the input space systematically. The single most convincing artifact in the report is the full code point sweep: one loop over U+0001 to U+10FFFF producing the exact count of affected inputs, 1,111,808. Systematic sweeps turn "it breaks on emoji" into a precise boundary, and precise boundaries point at implementation details like lookup tables.
- Vary one dimension at a time to find the edge. The reporter's table of set shapes is textbook: one character in the set, fine. Two or more all below U+0100, broken. Any member at or above U+0100, fine. Haystack restricted to Latin-1, fine. Four rows, and suddenly the shape of the optimized fast path is visible without reading a single instruction.
- Then, and only then, open the disassembler. Pull the exact shipped binary from the device, not one built locally. Locate the symbol, read the branch structure, and describe the mechanism in terms of what the code must be doing. Label it as reconstruction from the binary, not as fact about the source.
- File against the right project with the exact build fingerprints. The issue includes full build identifiers for each affected and unaffected device. That table let Google and every reader reproduce the boundary instantly.
- Check the existing tests for the same blind spot. If a regression shipped, ask what the tests were measuring. Sometimes, as here, the test suite has faithfully been asserting the wrong behavior for years.
Why I think this story matters more than one libc bug
The bug itself will be fixed. A clearly-scoped C standard regression with a minimal reproducer, an exact mechanism, and an engaged maintainer is the best-case scenario for open-source debugging.
The precedent is the thing to watch. "Open source" has quietly acquired a second meaning: the source exists publicly, at a release cadence disconnected from the binaries you actually run. Your phone ships code that no external person can audit, derived from sources that will not be visible for months, and the safety net is that someone whose pinball tables stopped loading cares enough to read assembly. That worked this time. It is not a process you can rely on.
There is an old debugging lesson buried in here too, one that has nothing to do with phones: when a function that has worked for thirty years suddenly misbehaves on exactly the inputs the fast path was written to avoid, suspect an optimization rewrite, and suspect that the tests share the same blind spot as the author. The test was wrong in the same direction as the code. That is not a coincidence; it is how shared blind spots get encoded.
I spent a good part of today reading the issue thread and the discussion around it, and the line that stuck with me came from the reporter's own aside: they went to look at the source, and the source was simply not there yet. Twenty years ago that sentence would have been about a proprietary driver. In 2026 it is about the flagship open-source mobile platform.
I write about debugging, developer tools, and the systems underneath them every week. Subscribe, it's free.
Have you ever had to debug a binary that did not match its public source? I would love to hear how you handled it in the comments.
One question before you go: how long do you think a source lag is acceptable before "open source" stops meaning anything for a platform? The comment box is open.
Top comments (0)