My fuzzer was optimising for my own bug
A flaky fuzz suite turned out to be a PTY I never configured, and a minimiser that hill-climbed straight toward the defect. It handed me the answer for two days while I ignored it.
Gaurav Gosain
TUIOS is a terminal window manager, so almost everything it does is visual and almost none of it is easy to assert on. A pane moves one column. A border picks up the focus colour. A wide glyph either fits its cell or eats a column of its neighbour. You cannot reach any of that from a Go test, because the thing under test only exists as bytes on a pseudo-terminal that some other program is supposed to interpret.
So I wrote tuitest. It spawns a program on a PTY, drives it, keeps a terminal emulator's worth of screen state on the other side, and lets you assert on what the program drew. Once that worked, writing the input by hand started to feel silly.
Fuzzing a TUI
A TUI has a wide input surface and nothing much protecting it. Keystrokes, mouse reports, resizes, bracketed pastes, text whose width the program has to get right. The bugs I ship in this area are almost never logic errors in a handler. They are combinations. A resize during a drag. A paste with a control character in it. A grapheme cluster that one layer measures as one column and another measures as two.
tuitest fuzz -- ./myapp
tuitest fuzz -seed 42 -iterations 200 -corpus testdata/fuzz -- ./myappEverything the generator emits is expressible as a tape command. The text corpus is deliberately nasty: ASCII mixed with accented Latin, CJK, ZWJ emoji, regional indicators, combining marks, zero-width and bidi overrides. Width handling is where layout code usually goes wrong.
Ctrl+c is in the key set even though it usually quits the program. That is on
purpose. The check for "did this program restore the terminal on the way out"
can only run against a program that has exited, and leaving someone's terminal
in raw mode with the cursor hidden is the single most common TUI bug there is.
A generator that never quits anything can never find it. Ctrl+z is excluded,
because suspending a child under a PTY just wedges the run.
When a run fails, the fuzzer shrinks the input and writes a tape. Candidates
replay through the same tape player tuitest run uses, so the file you get is
not a description of what the fuzzer did. It is the thing itself, and it runs
with no fuzz-specific machinery involved.
Then the fuzz suite started failing
Intermittently. Never the same test twice, and only when the whole package ran
under -race.
Both failure modes had the same shape. Something fails once, then refuses to reproduce:
fuzz_test.go:314: the corpus entry should still reproduce on replay, got: no failures
fuzz_test.go:188: the minimised reproduction did not reproduce on confirmationThe obvious reading is that the fuzzer's bookkeeping is broken. A failure recorded against the wrong input, or shrinking losing the case somewhere. I spent most of an evening inside the shrinker on that theory.
Before going further I wanted a number, because "sometimes" is not a bug report. Twelve sequential runs, then twenty-four across four parallel lanes:
No DATA RACE ever fired. Not once, in any of those runs. -race mattered
here because it slows the process down and reshuffles scheduling, not because
it detects anything, and I now reach for it as a timing perturbation about as
often as I reach for it as a race detector.
Reading the tape I had been ignoring
The shrinker had been reducing failures to two commands: spawn the program,
send a burst of ^C. I had written that off as a degenerate case.
Run those two commands twenty times, outside the package, and it fails four
times. Not zero, not twenty. On the failures the harness reported bytes=2, dirty=false, status="killed by interrupt", and the captured screen contained
this:
^C
tuitest created a PTY and never configured it. A fresh PTY comes up in the
kernel's default cooked mode, so every byte written to the master went through
the line discipline before it reached the program. 0x03 raised SIGINT and
killed the child. 0x13 stopped output through flow control and could hang a
session indefinitely. Everything the harness typed was echoed back down the
master and arrived at the screen model as though the program had printed it,
which quietly corrupted both the screen state and the byte counter the hang
detector reads.
All three of those are reproducible by hand:
^C with ISIG on. The program receives nothing, the child dies, and a literal ^C lands in the echo lane, which is the entire screen tuitest captured. Turn ISIG off and the same press arrives as an ordinary byte. Press ^S to stall output with nothing visibly wrong, then ^Q to revive it. The echo lane is the third bug: harness keystrokes coming back up the master, where a screen model counts them as output the program never wrote.Real TUIs hide this, because they call MakeRaw on startup. That is why it
survived so long. If your program reaches raw mode before the first byte lands,
everything works. It is a race, and any input sent during terminal setup still
belongs to the line discipline. Whether a tape drove a program or killed it
came down to how fast the child got scheduled, and under -race with the rest
of the package running, it got scheduled slower.
The shrinker was helping the bug
The shrinker is delta debugging. Remove chunks in decreasing sizes, simplify what survives, accept a candidate if it still fails. Still fails on one replay.
That criterion is fine for deterministic failures. For a race it is actively harmful. Every command that takes time gives the program more time to reach raw mode, which makes the race harder to lose, which makes the candidate less likely to fail. So the commands that suppress the bug are precisely the ones the shrinker deletes first.
Spawn ./myappType "hello"Key DownWaitStable @250msMouse Drag 10 4 20 12Key Ctrl+c Ctrl+c Ctrl+c
The tool was optimising toward its own defect, and the tape it handed me was not noise. It was the shortest path to the bug, written to a file, which I had skimmed and dismissed for two days.
If your minimiser accepts on a single execution, it is a search for flaky inputs. It will find them whether you asked for that or not.
Fixing it, and the line I nearly crossed
Configure the PTY before the child exists, so there is never a moment where a signal-generating line discipline sits in front of a running program.
The real decision is how much to clear, and my instinct was wrong. The instinct
is MakeRaw. But tuitest tests what programs do under a real terminal, and a
real terminal does apply line editing to a program that has not gone raw yet.
If the harness strips all of that, it stops reproducing the environment it
exists to reproduce.
So it clears only what reinterprets or manufactures bytes: ISIG, IEXTEN,
the echo flags, IXON, IXOFF, IXANY. ICANON and CR/NL mapping stay.
I know that boundary is right because I put it in the wrong place first. My
first attempt also cleared ICRNL, and the tape roundtrip tests failed
immediately. That was the codebase telling me I had crossed from "stop the
kernel eating my input" into "change what the program sees", and I was glad
something caught it.
The rest was portability. Termios constants live in different places across
platforms, and my first version silently broke the Solaris build.
Cross-compiling caught it: the constants now split across linux || solaris || aix and the BSD set, verified for darwin, freebsd, netbsd, openbsd, solaris
and aix/ppc64.
Forty-four runs after the fix, zero failures. The two-command tape reproduces
20 in 20 with bytes=947, dirty=true, status="exit status 0".
While I was in there
The same audit turned up a second hole, unrelated to the PTY. The emulator
generated correct replies to terminal queries and nothing ever carried them
back to the program that asked, which is why tuitest snap -- glow -p file.md
captured a blank screen and exited 0. That turned out to be one instance of a
bug I had already hit twice in other codebases, so it has
its own post. The version relevant here: my own
source contained comments describing the component that would carry replies
back, and the component did not exist. Fixing it also broke a test that had
been green since the day it was written, without once reaching the scenario it
was named after.
Odds and ends
--duration was only checked between iterations, so --duration 5s ran 6.47s
and --duration 20s ran 54s at default action counts. Now 5.09s and 20.27s.
The closest-line heuristic in failure output scored by shared prefix length, so
against /a headless testing framework for TUIs/ it helpfully suggested the
four-character fragment a VT. Replaced with normalised edit distance.
A blank capture now exits 5 instead of 0, with a note about --timeout and
--wait if the program was still running when the capture was taken.
What I would keep
The fuzzer found almost nothing in the program it was pointed at. It found bugs in the harness, a bug in a test, and a bug in its own shrinker. For a tool whose entire job is telling the truth about what a program did, I think that is the better outcome, though it did not feel like one at the time.
The habit I am actually keeping is counting. "Sometimes" cost me an evening in the wrong file. Seventeen in thirty-six took two minutes to establish and immediately ruled out half of what I had been considering. And I will read the minimised output even when it looks stupid, especially when it looks stupid, because mine was correct and specific for two days while I ignored it.