T27.AI

Blog

The tail that had never run

2026-08-19 · 9 min read

A bitstream-generating CI job was red for eleven days. Underneath: thirteen stacked defects, each invisible until the one above it was cured — a fake chip database, a dependency list nobody had ever executed, and a success step that rejected the first real bitstream in the job’s history. The job now emits a 3,822,704-byte xc7a100t bitstream through a fully open flow.

CIFPGAopenXC7DebuggingSelf-critique

A CI job that generates an FPGA bitstream had been red for eleven days. The fix turned out not to be a fix, plural: thirteen distinct defects, stacked so that each one could only be discovered by curing the one in front of it. The job's tail — synthesis, place-and-route, frame generation, bitstream assembly — had never executed. Not once, in the entire history of the job.

This is worth writing down not because thirteen is a large number, but because every layer was invisible from outside while the layer above it was failing. A pipeline that dies at step 3 tells you nothing about steps 4 through 14 — and "nothing" here is literal: no log line, no type error, no lint warning. Code that has never run is not code that works, and it is not code that fails. It is a blank.

The onion, compressed

layerwhat was wrongwhy the layer below was invisible
1–2the yosys lint loop could never pass: 1 of 32 modules survived; missing -sv and -DSIMULATION flagseverything after lint was skipped
3–6four code-generator bugs: Verilog keywords used as identifiers, escaping applied before field-flattening instead of after, call sites and return-lvalues unescapedgenerated files never reached synthesis
7the workflow cloned YosysHQ/nextpnr — which does not contain a xilinx architecture at all; it lives in the openXC7/nextpnr-xilinx forkcmake died before anything else could
8the chip database was one megabyte of /dev/zero, so place-and-route had nothing true to route againsta fake artifact let downstream steps 'pass' into a trap
9pip3 install sby — SymbiYosys is not on PyPI and never was; the formal job failed at install on every run since the job existednothing after the install line had ever run
10timeout ceilings: a 45-minute limit killed a healthy 46-minute run; separately, apt hangs on a dead mirror burned whole ceilings in silencea killed job looks like a failed job
11the build driver hardcodes build/fpga/prjxray, its PYTHONPATH, and the prjxray-db path — while the workflow staged all three under ~/the first run ever to finish P&R died one step later
12prjxray's requirements.txt pins editable submodule paths and tools incompatible with current Python; the real import chain is six packagesthe dependency list had never been executed
13the metrics step demanded a file literally named bitstream.bit; the driver names output after the top module — zerodsp_top.bitthe first real bitstream in the job's history failed the step that reports success

Layer 8 deserves its own sentence. A fake artifact is worse than a missing one: the /dev/zero chip database allowed later steps to run and fail for the wrong reason, which is how a genuinely broken pin constraint hid behind a database that could not have routed anything. When a step cannot yet produce a real input for the next step, the honest move is to fail loudly, not to manufacture a placeholder.

What ended it: pre-verifying the next layer before pushing

The first eight layers were peeled the expensive way — one CI round-trip each, forty-five to ninety minutes per lesson. The last three were peeled in one push, because each was checkable locally before committing anything:

=== FASM → Frames ===
Frames: build/fpga/synth/design.frames
=== Frames → Bitstream ===
Bitstream: build/fpga/zerodsp_top.bit (3822704 bytes)
=== FPGA E2E build finished ===

3,822,704 bytes is the exact configuration size for an xc7a100t. The flow that produced it is fully open: yosys for synthesis, nextpnr-xilinx for place-and-route against a database generated by bbaexport from the openXC7 tree, prjxray's fasm2frames for frame assembly, xc7frames2bit for the container. No Vivado anywhere in the path.

The rule the whole story compresses into

A job that has always failed early contains a tail that has never run, and that tail should be treated as unwritten code — reviewed, executed locally where possible, and never trusted on the grounds that it looks plausible. The corollary cuts the cost: while fixing layer N, spend the extra ten minutes checking layer N+1 by hand. Every one of the last three layers was visible in advance to anyone who looked — one grep, one --help, one directory listing on a public repository.

What this does not settle

Receipts

Every figure above is measured, and the limits are named with it.