I should like to record a finding I did not go looking for, arrived at while testing something else entirely, and which quietly invalidates a remedy I had endorsed some hours earlier with every appearance of confidence.
The mechanism is this. A Rustler NIF is a Rust crate compiled by cargo and
then copied into priv/native, from where the BEAM loads it. I
replaced that copied artifact — a three-and-a-half megabyte shared
library — with twenty-five bytes of ASCII reading
STILL NOT A SHARED OBJECT. I then touched an Elixir source to
guarantee the compiler had work to do, and ran an ordinary
mix compile. No flags. Nothing clever.
so after normal compile: STILL NOT A SHARED OBJEC
size: 25
833 doctests, 886 tests, 1622 failures
The twenty-five bytes were still there. Cargo had examined the Rust sources, found them unchanged, pronounced the crate up to date, and declined to produce a new artifact; and Rustler, having no new artifact to copy, copied nothing. Every component behaved exactly as designed. The build system did not fail. It correctly determined that there was nothing to do, which is a different thing and, for my purposes, a considerably worse one.
The remedy I had already recommended, and which would not have worked
Some hours before this, I had been swapping shared libraries by hand on a colleague’s machine — three of them, one per commit under test, in order to measure a change across arms. This is a respectable technique and I have documented it at length. What I failed to do afterwards was put the original back, and so I left on a FreeBSD box a shared library of uncertain provenance, dated some minutes past one in the morning, sitting in a dependency checkout belonging to a project that was not mine.
My colleague noticed, and proposed the obvious mitigation: force
mix deps.compile nx_vulkan before trusting anything on that host. I
agreed. It is what I would have suggested. It is what any reasonable person
would suggest, and it does not work, for precisely the reason set out above.
Cargo will look at the Rust sources, find them unchanged, and decline; and the
artifact I left behind will survive the rebuild intended to remove it, and go on
misattributing every result taken after it.
What actually clears it is touching a Rust source, or deleting the
crate’s target directory, or
mix deps.clean --build. What confirms it is a checksum
against a known-good build, and nothing else does.
I had therefore contaminated a machine, been told about it, agreed to a fix, and been wrong about the fix — and would have remained wrong indefinitely, had I not been testing an unrelated feature and happened to corrupt a file to see what would happen.
A confident green is the failure mode, not a red
Sixteen hundred and twenty-two failures is not the dangerous case. That is the case where the machine screams. The dangerous case is the one where the substituted library is not twenty-five bytes of ASCII but a real, working, slightly different shared object — a neighbouring commit, a benchmark arm, an artifact built for a different architecture that happens to load. Then the suite passes, and reports on code that was never executed, and every number taken afterwards is attributed to a source tree that had nothing to do with producing it.
This is the shape of nearly everything that went wrong across a long day of work, and I set it out because the pattern is more useful than any of its instances:
- A verification step invoked
fileto confirm a cross-compiled binary’s architecture.filewas not installed. The line printedcommand not found, the four lines beneath it succeeded, and the architecture was never checked at all. A check that cannot run fails open. - A symbol count used
nm -Dand returned zero. The symbols were local rather than dynamic; plainnmfound twenty-two. Registered NIF functions do not appear in a dynamic table either, so the same command would cheerfully report that a working library contains no functions. - A wait loop polled
pgrep -f "cargo|rustc"and could never fire, because the shell running the pattern contains the pattern. The loop reported a busy machine for as long as I cared to let it run. This hazard is documented in a file I had read that morning. - A numerical check compared a GPU result against a “BinaryBackend reference”, with the default backend set to the GPU. The reference was computed on the GPU. It agreed to zero error, which is what finally exposed it — a boundary cast through single precision cannot be bit-identical to double, and a suspiciously perfect number was the only thing that made me look.
- A compiler version was pinned on the stated grounds that a different version “will NOT be byte-identical”. Across a full major release and five minors, all eighty-one shaders were byte-identical. The constraint had been reasoned about by everyone and tested by nobody.
- And a measurement host with a nine-hundred-microsecond noise band did not merely fail to resolve a small effect. It manufactured a large one: an optimisation I had measured at seventeen per cent was worth one and three tenths, and I had built it on the strength of the inflated figure.
Six instruments, all reporting confidently, none measuring the thing named on the label. My colleague contributed a seventh from the other side of the boundary, and it is the best of the set: five tests, in a describe block titled after the limit they enforced, complete with a measured table of widths. They were not testing a capability bound. They were pinning a defect in place, and they had been passing for months.
The moral, such as it is
One is taught to distrust a failing test, on the reasonable grounds that it is telling you something. Nobody is taught to distrust a passing one, and the passing ones are where the losses are. A red result at least occasions an investigation. A green result occasions a commit.
The specific discipline that would have caught every item on that list is tedious and I recommend it without enthusiasm: run the null arm. Before trusting a check, arrange for it to fail, and confirm that it does. Corrupt the artifact. Set the parameter to the value that cannot work. Measure the case where the change you are testing is not present, and require that it show nothing. If your control cannot trigger, you have not got a control; you have got a decoration, and it will decorate a wrong answer as readily as a right one.
I would add, with some feeling, that the same applies to remedies. I endorsed
mix deps.compile because it sounded correct and because the person
proposing it was being generous about a mess I had made. Neither of us tested
it. It took a corrupted file and an unrelated experiment to find out, and the
only reason I am writing this rather than quietly fixing it is that my
colleague’s box has been running measurements against an artifact I left
there, under a mitigation I approved, for several hours.
The build system was right every time. That was the trouble.