DEV Community

Cover image for Everything worked when I looked at it. That was the bug.
Den
Den

Posted on

Everything worked when I looked at it. That was the bug.

In the last post
I opened the source of Boolflow, my browser-based
digital logic simulator, and found that the roadmap I had confidently published
was in the wrong order. I ended it planning to build an explicit tick counter.

I did not build the tick counter. I opened the deployment instead, and spent the
day finding four things that had been broken for months while looking completely
fine.

That is the thread running through all of them. Not one of these bugs made
anything fail in front of me. Every single one was hidden by the normal path
working exactly as intended.

The prerenderer I wrote in June went live in August

Boolflow is a Vite SPA with a prerender step: prerender.mjs walks a route
table, renders each page with renderToString, and writes a real HTML file per
URL. I wrote it on 4 June. It has been sitting in the repository ever since,
correct and tested and doing nothing at all.

Here is what production actually served, on every URL:

/                          200  4493 bytes
/help                      200  4493 bytes
/ru/help                   200  4493 bytes
/ru/articles/karnaugh-maps 200  4493 bytes
/verilog                   200  4493 bytes
/nonexistent-page-xyz      200  4493 bytes
Enter fullscreen mode Exit fullscreen mode

The same 4.5 KB on all of them. Same <title>. Same description. <div
id="root"></div>
— empty. It was the Vite build output before the prerender
step overwrote it.

The cause is three lines of package.json:

"build":       "vite build",
"prerender":   "node prerender.mjs",
"build:full":  "npm run build && npm run build:server && npm run prerender",
Enter fullscreen mode Exit fullscreen mode

And one line of my own deployment guide, step 11, "Updating the application":

npm run build
Enter fullscreen mode Exit fullscreen mode

The prerender lived in build:full. The documented update procedure called
build. Not sometimes — structurally, every time, for three months. The
deployment instructions I wrote could not have produced a prerendered site if I
had followed them perfectly.

And here is why I never noticed: the site looks perfect in a browser. You
request /ru/help, get 4.5 KB of empty shell, the JavaScript loads, React
hydrates, and the Russian help page appears. Every human visitor, including me,
saw a working site. Only crawlers — and anything else that does not execute
JavaScript — saw the shell.

The fix is embarrassingly small. build is now the whole pipeline:

"build":        "npm run build:client && npm run build:server && npm run prerender",
"build:client": "vite build",
Enter fullscreen mode Exit fullscreen mode

You can no longer deploy half a build, because there is no longer a script that
produces half a build.

Meanwhile, every page told Google it was the homepage

The shell has a static <link rel="canonical" href="https://boolflow.site/">
correct for the landing page, meaningless for the other sixty-four URLs it was
being served as.

So the sitemap advertised 65 URLs, and all 65 responded with the same title, the
same description, and a canonical tag pointing at the homepage. That is not
"pages ranking badly." That is me explicitly instructing Google to collapse the
entire site into one page.

Three smaller things were wrong in the same layer, and all three had the same
character — a thing that returns 200 and looks fine:

  • /nonexistent-page-xyz returned 200. Every typo was an indexable page.
  • www.boolflow.site returned 200 with no redirect, so the whole site existed twice, on two hosts, with canonicals pointing at only one of them.
  • The sitemap was hand-maintained next to a script that already knew the exact route list. It had drifted, of course. It now gets generated from that route list, so it cannot advertise a URL that has no page.

The URL said Russian. The code asked localStorage.

Boolflow has EN/RU/DE. Articles and help live under /ru/… and /de/…. The
language context read the language from exactly one place:

const [lang, setLang] = useState(() => localStorage.getItem('boolflow-lang') || 'EN');
Enter fullscreen mode Exit fullscreen mode

The URL is not in that expression. So /ru/help rendered its body from the
path and its chrome from localStorage, and the prerendered file proves it — a
Russian <h1> under an English header:

dist/ru/help  →  header: "How to use Boolflow"
                 h1:     "Справка Boolflow"
Enter fullscreen mode Exit fullscreen mode

Worse, the provider also set document.documentElement.lang from localStorage,
so hydration rewrote the prerendered lang="ru" back to lang="en".

The URL prefix now wins, falling back to the stored preference only on paths
that have no prefix. One consequence I did not expect: the moment the URL became
authoritative, I found the translations for the landing page and all six tool
pages were already written — full EN/RU/DE copy, sitting in the source,
reachable only as a localStorage state. They had no addresses. Google had never
seen them and could not have.

Giving them addresses took an afternoon and added fourteen pages of content that
already existed.

The bug that survived because it was usually idempotent

This is the one I am actually pleased about, because it validated the last post
in a way I did not intend.

Stateful elements — flip-flops, counters, latches — keep their state in a
module-level Map, keyed by node id, mutated in place:

const st = nodeStates.get(n.id) ?? { q: 0, prevClk: 0 };
let q = st.q;
if (clk === 1 && st.prevClk === 0) q = d ?? 0;   // rising edge
nodeStates.set(n.id, { q, prevClk: clk ?? 0 });
Enter fullscreen mode Exit fullscreen mode

simulate() runs more than once per logical tick. Boolflow has four layers, and
signals cross between them through named VIA elements, so the layer loop
simulates everything once, discovers the VIA values, then re-simulates the
layers that received them.

Normally that is harmless. Run it twice with the same inputs and you get the
same answer, because prevClk has already caught up and there is no edge left
to detect.

Except a VIA delivers its value on the second pass. So:

  1. Clock goes 0 → 1. Pass one runs. D is still unresolved, null. The flip-flop sees the rising edge and latches 0. prevClk becomes 1.
  2. The VIA value arrives. The layer is re-simulated with the real D.
  3. prevClk is already 1. There is no edge. Nothing happens.

The flip-flop latches a stale value, and only when its data crosses a layer
boundary — which is the entire reason the layer feature exists.

Here is the test, replaying the layer loop verbatim with the same flip-flop wired
two ways:

D flip-flop, D held at 1, one rising clock edge. Expected Q = 1.

  same layer, direct wire   ->  Q = 1   ok
  D arrives through a VIA   ->  Q = 0   WRONG
Enter fullscreen mode Exit fullscreen mode

The fix is to stop mutating one map. State is now split: committed is what it
was at the start of the tick, working is what is being computed during it.
Every pass reads from committed, so repeated passes recompute the same
transition
with progressively better inputs instead of racing to consume the
edge first.

  same layer, direct wire   ->  Q = 1   ok
  D arrives through a VIA   ->  Q = 1   ok
Enter fullscreen mode Exit fullscreen mode

Now the part I like. In the last post I argued that an explicit tick counter had
to come before waveforms and sequential tests, and I argued it on architectural
grounds — the engine has no notion of time, so features that need time cannot be
built honestly. It was a good argument and it was entirely theoretical.

It turns out the engine not knowing when a tick begins was not a future problem.
It was corrupting multi-layer clocked circuits in production. The invisible
refactor I was defending on taste was a bug fix with a reproduction case.

A captcha that rendered perfectly and could not be read

Admin login has an image captcha. I could not get past it. My first assumption
was that I was typing it wrong.

The endpoint is healthy: 200, image/png, 11875 bytes, correct https URL.
So I downloaded three and looked at them. 4U2S2 — the last 2 sliced off by
the right edge. W3NPQ — the Q running into the border.

Then the arithmetic. Each character is drawn into a 36×42 box and rotated up to
25° with expand=1, which makes it bigger than its box:

36·cos25° + 42·sin25° ≈ 50 px wide
36·sin25° + 42·cos25° ≈ 53 px tall
Enter fullscreen mode Exit fullscreen mode

The canvas was 150 × 50. A single glyph did not fit vertically. And with a
start of x = 10 and a random advance of 22–26 px, the fifth character began at
98–114 and ended at 148–164 — off the right edge of a 150 px image.

Two round numbers, chosen once, never checked against the thing they had to
hold. The canvas is now derived from the glyph geometry rather than guessed:

canvas: 169 × 60   glyph: 49 × 52
right edge of last character: 161 ≤ 169
Enter fullscreen mode Exit fullscreen mode

I also dropped the remaining lookalike characters — Q Z 2 S 5 B 8 6 V on top of
the O 0 I 1 L that were already excluded — leaving 22 characters and about 5.1
million combinations, which is plenty for something that allows five guesses.

To check it honestly I generated four captchas without looking at the answers,
read them, and only then compared. Three for three.

The tool pages had nothing to say to a crawler

An SEO audit flagged three URLs as thin. I counted the rendered words myself:

    9  /check         "All connections are complete."
   13  /truth-table   "No inputs or outputs on this layer."
   22  /verilog
Enter fullscreen mode Exit fullscreen mode

The audit's advice was the usual boilerplate about ensuring sufficient word
count. Word count is not a ranking factor and padding pages is how you get
useless pages, so I ignored the advice — but the diagnosis was correct, and the
reason was structural rather than editorial.

These pages render the result of processing a circuit. With no circuit there is
nothing to render. And a crawler will never load a circuit — nor will a human
arriving from a search for "convert logic circuit to verilog", who lands on an
empty box and leaves.

So each tool now carries an explanatory section that does not depend on editor
state: what the tool does, a real sample of its output, the limitations, a link
to the relevant article. The samples are taken from what the exporters actually
emit, because a fake code sample in a page about generating code would be a
strange thing to ship.

   22 → 235  /verilog        9 → 219  /check
   13 → 220  /truth-table   21 → 259  /cpp
Enter fullscreen mode Exit fullscreen mode

What the day actually cost

Honest accounting: I closed one of five roadmap items, and only half of it —
combinational test cases, which turned out to need none of the refactors I said
they needed. Everything else was infrastructure, and none of it was on any plan.

What changed for users: nothing visible. What changed for everyone who has ever
searched for a logic simulator: eighty-one pages that were previously
unreachable, in three languages, are now crawlable for the first time.

The pattern

I keep coming back to the shape of these four.

The site rendered. The flip-flop latched. The captcha returned a valid PNG. The
tool pages worked when you used them. In every case the thing I would naturally
check was the thing that was working, and the failure lived one step off the path
I ever walked: no JavaScript, a signal arriving late, the fifth character, an
empty editor.

The uncomfortable version of this lesson is that the more reliable your happy
path, the better it hides everything else. I have no clean method to offer. The
only thing that worked was going and looking at the artifact directly — curl
the URL instead of opening it, count the bytes, download the image and actually
look at it, replay the loop with the state printed.

Every one of these was found by looking at the output rather than at the code
that produces it.


Boolflow is free and open in the browser at boolflow.site.
The tick counter is next. This time I have a reproduction case for why.

Top comments (0)