keal-view / Testing a new backend

Testing a new backend

runtime/kv_cocoa.m is verified: keal-view was developed on a Mac. runtime/kv_win32.c and runtime/kv_x11.c were written against the same dozen functions, compile on their own platforms in CI, and have never opened a window. This is the checklist for the first person who does.

Read it top to bottom. The order matters: it separates "the framework is broken" from "the backend is broken" before you have to guess.


0. What you need

Windows — MinGW-w64 in a POSIX-threads flavour, or LLVM clang targeting mingw32. MSVC cannot compile Keal's runtime: it uses GCC's overflow builtins. keal doctor says which compiler it found. You also need a bash — Git Bash or MSYS2 — because tools/build.sh is a /bin/sh script.

Linux — a C compiler and libx11-dev (Debian, Ubuntu) or libX11-devel (Fedora), plus a TrueType font: fonts-dejavu-core is what CI installs.

Both — Rust, to build the Keal compiler.

git clone https://github.com/geneacta/keal
git clone https://github.com/geneacta/keal-view
cd keal && cargo build --release && cd ../keal-view
tools/build.sh examples/gallery.keal

The compiler moves too. keal-view uses language features that arrived because keal-view asked for them, so git pull in ../keal and cargo build --release are part of updating this repository, not a separate errand. A stale compiler refuses with the C backend cannot compile …, naming the construct — that message means the compiler is behind, not that your machine is at fault.

And do not pipe tools/build.sh into anything. A pipeline's exit status is the last command's, so tools/build.sh … | tail -2 && ./units runs the old binary after a failed build and tells you nothing about why it behaves strangely. tools/test.sh prints the compiler it used, for the same reason.


1. Before the window: is the framework itself alright?

build/units                                        # or build/units.exe
build/gallery --snapshot frame.png 2 900 1900

Two commands and nothing else installed: the PNG is written by src/png.keal, in Keal, so this step needs no Python and no image library on a machine you are still porting to.

units prints how many checks it ran; the number grows, so compare it against what it says on a machine that works rather than against a number written down here.

Neither touches the window, the event queue or the platform's drawing at all. The first asserts several hundred things about colour, geometry, layout, docking, fonts, pictures and input dispatch. The second exercises the rasteriser, the TrueType engine and the layout, and writes the result to a file.

This is the bisection. If frame.png is right and the window is wrong, the fault is in the backend and nowhere else. If frame.png is wrong, the fault is above the backend and every platform has it.

Look for, in frame.png:

  • letters, not empty rectangles. Rectangles mean kvp_font_path found no font. It tries segoeui.ttf, tahoma.ttf, arial.ttf under C:\Windows\Fonts; on Linux, DejaVu, Liberation, FreeSans and Noto under the usual /usr/share/fonts paths. Add yours to that list if it is somewhere else.
  • sharp text, not blurred or doubled.
  • round corners that are round, and borders one pixel thick.

2. The window, in the order things go wrong

  1. It opens, and comes to the front. A process started from a terminal is not always allowed the foreground.
  2. The picture is the right way up. Windows passes a DIB of negative height for top-down rows; X11 sets the image's byte_order to LSBFirst and lets Xlib convert. If everything is upside down, that is the line.
  3. The colours are right. The framebuffer is 0xAARRGGBB in a uint32_t, and all three platforms are supposed to read that without conversion. Red and blue swapped means they do not, on yours.
  4. Resizing — drag a corner. It should redraw without lag or a white flash. A white flash on Windows is WM_ERASEBKGND, which the backend does not handle yet.

Tearing during a fast drag is not testable this way, and saying so is better than guessing. Repeated screen capture tops out around sixty milliseconds between frames, and a tear lives inside one frame; --trace counts frames rather than looking at them. A report that says "neither seen nor ruled out" is worth more here than one that says "looks fine". It would take a capture card or a camera, and nobody has needed it enough yet.

  1. Scale. On a display at 125 %, 150 % or 2×, everything should be larger and still sharp, never stretched and soft. If your screen is at 100 % you cannot see this, and you must not change the setting to find out — it is not your machine. You can still check the half that does not need eyes, and it is the half worth checking: ask the live window whether it is DPI-aware at all. On Windows, AreDpiAwarenessContextsEqual(GetWindowDpiAwarenessContext(hwnd), (void*)-4) answers whether PER_MONITOR_AWARE_V2 took. Beware GetAwarenessFromDpiAwarenessContext, which answers 2 for both V1 and V2 — the enumeration has no value for V2, and only the comparison distinguishes them. Windows asks GetDpiForWindow after SetProcessDpiAwarenessContext; X11 reads Xft.dpi from the resource database. Soft, stretched output means the process was not made DPI-aware and the system is scaling the window for it.
  2. The pointer shape. An arrow over most things, an I-beam over the text fields in the gallery's Saisie section, and over the dividers in the studio. Flicker, or a shape that never changes, is the cursor path.
  3. Hover. Buttons lighten under the pointer. This is the whole mouse-move path in one glance — with the window focused, which is the case to test. Whether hover also works while the window is not focused is left to the platform and is not a defect either way: Windows and X11 deliver pointer motion to an unfocused window and so it does, macOS delivers none to an inactive application and so it does not. Making them agree would mean overriding one platform's own convention, which is a worse answer than this sentence.
  4. Typing. Click a field and type. Then type an accented character, and one outside Latin-1 if your keyboard has one. Both backends decode to UTF-8 by hand — Windows folds surrogate pairs, X11 takes what XLookupString gives — and neither path has ever run.
  5. Clipboard. This one is only testable by hand: on X11 a selection is owned by a window, so build/units — which never opens one — skips it and says so, even on a machine whose display is answering. Select in a field of the gallery, Ctrl+C, then Ctrl+V somewhere else. On X11 this is a real selection owner answering real SelectionRequest events; try pasting into another application too, and copying from one. Do it quickly, several times: OpenClipboard on Windows is a lock the system's clipboard-history service also takes, and a write followed immediately by a read is where that shows. The backend retries; a failure here would mean it does not retry enough.
  6. The wheel, over the scrolling list in the gallery's Regroupements section.
  7. A double click in a text field should select a word's worth of ground — at minimum, clicks should reach 2.
  8. The title bar. Dark where the system offers it: Windows tries DwmSetWindowAttribute 20 then 19, and older builds have neither — a light bar over a dark window there is correct, not a bug. X11 has no title bar of its own at all; the window manager draws it, and _GTK_THEME_VARIANT is a hint it may ignore.
  9. The processor at rest. Window open, nothing animating, and — this is the part that matters — measure it twice: once with the pointer well away from the window, and once with the pointer resting over it and not moving. Both should be 0,0 %; a stationary cursor is not an event. The second case was broken on Windows for two days while the first was perfect, so one reading here says almost nothing.

Under XWayland the second case cannot be synthesised, and this step is then half done. XTestFakeMotionEvent is advertised and inert behind the input portal, and XWarpPointer moves the pointer without the server emitting the motion that would follow — so a program cannot put the compositor into the state where a cursor is resting on a surface. Only a hand on a mouse can. If you cannot get that, report this step as half done rather than as passing: the half you can measure is the half that was perfect on Windows while the other one held a core. Take it from accumulated processor time over several seconds rather than from a one-second sample, and run the same measurement against a text editor as a control.

  1. Closing. The close box ends the process, with nothing left running.

Then the two real applications:

build/calculator        # digits, +, Enter, Escape from the keyboard too
build/studio            # the one that matters

In the studio: drag a panel by its tab onto another one. A blue region should show where letting go would put it, before you let go — left, right, above, below, or as another tab. Drag the dividers between panels. Close a tab with its ×. Press Ranger to put it all back.


2⅜. A key that navigates must not type

Everything in §1 goes through events this repository made up, which is the right way to test the framework and cannot test the backend — the thing that makes the events. So there is a bench for that, and it needs a person:

tools/build.sh tests/bench.keal && build/bench

A text field at the top, an editor below it, and a character count at the bottom. The count is the instrument. Press every arrow, Home, End, Page Up and Down, and forward Delete, and the count must not move. Then Shift with each of them, and the selection must appear without the text changing.

That test takes a minute and found a defect that had been there since the first day. On macOS, Cocoa answers a character for keys that type nothing: the arrows, Home, End, the function row and forward Delete all come back as one code point in Unicode's private-use area, U+F700 upwards. They are above 32 and are not 127, so the guard that drops control characters let every one of them through — and one press of Left added one character to a document, 72 to 73, counted on the screen. Home, End and Shift+Home in sequence replaced a whole line with a single unrenderable box, in a field as readily as in an editor.

Neither of the other two backends can do it. Windows sends no WM_CHAR for a key that types nothing, and Xutf8LookupString answers no bytes for one. It was Cocoa's alone, and it survived a thousand assertions and three testers because nobody had ever pressed an arrow key in a keal-view window on that platform. Everything there had gone through --snapshot, or through synthesised events above the backend, and this was the backend inventing one.

The lesson generalises to any backend you write: the question is not whether your text events are right, it is whether you make one where there should be none.

Run on: macOS. Not yet on Windows or on Linux. Reading both backends says neither can have this defect — but reading is exactly what missed it on macOS for a week, so until somebody has pressed the keys the answer for those two is probably, and it is written here as probably rather than in the table as verified. A Wayland session cannot press its own keys: XTest is advertised there and inert behind the portal, so this test needs a person and not a harness.

2⅖. Two instruments that need a person, and what each is for

Both live in this repository, both take a minute, and neither can be a gate — one needs a window and a keyboard, the other needs a browser.

tools/build.sh tests/bench.keal && build/bench       # §2⅜ — a key that navigates
tools/site-width.sh                                  # the site, at 500 points
tools/build.sh tests/paintcost.keal && build/paintcost  # what a frame costs

The second is not about the framework at all; it is here because it belongs beside the first. A page whose content is wider than the window scrolls sideways as a whole, and a screenshot cannot show it: headless Chrome will not make a window narrower than 500 points, so a capture asked for at 375 is a 500-point page cropped, and it looks the same whether the defect is there or not. scrollWidth against clientWidth is what tells the truth. It found two things the first time it ran, one of them a 605-point floor under this very page.

The third is for the days the language changes underneath. This framework and the language it is written in are developed together, and a change that costs a few per cent on a compiler compiling itself can cost more here: a paint loop is dense in small calls. Run it before and after and compare — one reading says nothing, the machine is usually busy, and six runs took a minute.

It earned its place the first time it ran. A call trace added at every runtime failure site cost the language +2.6 % on a compiler compiling itself and +11 to +35 % here, depending on which of the three columns you read — and the spread is the finding: the more work a call does, the less the frame pays. A measurement taken from outside cannot separate the language's change from your own, so take it before and after one change, and say which when you cannot.

A mutation that does not compile is not a mutation that escaped. This is about the other instrument used constantly here — breaking the code on purpose to see whether an assertion notices. If the build fails and the runner is not told, it runs the previous binary, every check passes, and the report reads "not caught" for a change that never ran. One mutation in this project's history did exactly that and was investigated as a gap in the tests. So:

if ! tools/build.sh tests/units.keal >/dev/null 2>&1; then
  echo "DID NOT BUILD — says nothing"; return
fi

The same shape as everything below: an instrument that answers without measuring is worse than none, because it is believed.

**And site-width.sh was wrong three times before it was right**, which is the part worth carrying to any instrument you write here:

  • it grepped the page for its own answer, and --dump-dom prints a script's source as well as its result — so the pattern matched the source, found no number, and reported every page clean. Every page, including one that was 605 wide;
  • it measured inside an iframe to get under Chrome's 500-point floor, and web fonts in a file:// iframe never finish loading there — so it measured a page set in fallback faces, which is narrower than the page a reader gets;
  • it wrote its instrumented copy into a temp directory, and a page links its stylesheet by a relative path — so it measured unstyled pages and reported twelve of twenty-four overflowing when none of them did.

Wrong three ways, and each time it answered. An instrument that answers without measuring is worse than no instrument, because it is believed. What settled it was the same control this document asks of everybody else: take the fix out, and see the tool go red on exactly the two pages that then overflow — and at the same number an independent measurement had already given. A tool that has never been seen to fail has not been seen to work.

2½. Before you report an input problem, run the control

Both false positives in this project's first two test passes came from the harness rather than the framework, and both were caught the same way: doing the same thing to a program that is known to work.

Arrow keys sent with SendInput and no KEYEVENTF_EXTENDEDKEY are treated as the numeric keypad's, and Windows then cancels the Shift around them — so Shift+Arrow selected nothing, in a way that looked exactly like a missing selection. A burst of synthesised keystrokes dropped characters, which looked exactly like a full event queue, until the same burst was sent to Notepad and dropped them there too. An INPUT struct built at 56 bytes instead of 40 made SendInput refuse in silence, so a click simply did not happen and the window looked like it was ignoring input. And on X11 under XWayland, XTestFakeMotionEvent is advertised and does nothing at all, so the first report of "hover does not work" was about the injector.

And on macOS, System Events' click at {x, y} is accepted by osascript, returns success, and posts nothing that a window receives — so the first pass at a double click looked exactly like an editor ignoring the mouse. The control said otherwise within a minute: the same click did not reach a text field that had been taking clicks for a week. A pointer built on CGEventCreateMouseEvent and posted to kCGHIDEventTap works, and then the double click selected its word and a drag selected across four lines.

Five, from three people, on three platforms. In each case what caught it was the same: an observable that should have moved and did not, checked against a program known to work — or, where no such program would answer synthetic events, against a control window written to do exactly what the framework does.

So: if input seems to be lost or ignored, send the same input to a text editor first. If it is lost there as well, the report is about your injection and not about this framework.

2⅝. The two things in this repository that did the finding

Worth knowing before you build anything of your own, because both were here already and both were used by people who had not written them.

The control (§2½) caught seven false starts across two testers and two platforms — a wrong-sized INPUT struct, arrow keys without KEYEVENTF_EXTENDEDKEY, a dropped keystroke burst, an inert XTestFakeMotionEvent, captures a beat behind the window manager, a layout believed reset, and a stale window photographed instead of the fresh one. Not one reached the code.

**--trace found three causes.** A hover defect took four rounds to fix, and three of those rounds ended with the tester able to say where it had stopped rather than that it still did not work. wakes=1 frames=0 on a pointer moving onto a chart is a fact no screenshot gives — a screenshot says only that nothing changed — and no amount of reading the source would have produced it either.

2⅔. A leak is not a wrong answer, so no assertion will find one

The suite has nine hundred and fifty checks and every one of them passes on a select that leaks a view per rebuild, because a leaked object still gives the right answer. It has to be looked for with a different instrument, and it is worth five minutes on any platform:

leaks --atExit -- ./gallery --snapshot g.bmp 2 900 1900     # macOS
valgrind --leak-check=full ./gallery --snapshot g.bmp 2 900 1900   # Linux

Then build the same thing many times and see whether the number moves. A handful of objects held at exit is nothing; the same handful per rebuild is a window that grows while it sits there. Ten lines of Keal in a loop is enough to tell the two apart, and it is what separated them here:

   100 builds:    3 600 leaks
 1 000 builds:   36 000 leaks
 5 000 builds:  180 000 leaks

Thirty-six objects a rebuild, from select and menuButton each holding a function that held the view that held the function. A reference count cannot free a cycle, and a tree that is thrown away and rebuilt whenever anything changes makes one every frame — about ten megabytes a minute from a window with a dropdown on it and nobody touching it. What it looked like at exit was eighteen small objects, which is what a leak checker run once reports and what anybody would have shrugged at.

The rule that came out of it: a handler must not close over the view it is attached to. tappableAt hands it the rectangle instead, which is the only thing such a handler ever wanted from it.

2¾. Measure by comparing, not by reading a number

The two measurements that cut through this project's hardest defect both did the same thing, and it is worth copying the shape rather than the numbers.

User time against kernel time, in the same process. An idle window held most of a core; 97 % of it was user time and 0.3 % kernel. That one ratio eliminated the wait, the message pump and every system call in a single line, while the person who wrote the code was looking straight at them.

Two windows of exactly the same size, holding different amounts. A calculator with twenty buttons and a gallery with a scrolling document, both at 336×509: 0.93 % against 20.53 %. Same pixels, twenty-two times the cost — which eliminates the surface, the rasteriser and the platform at once and leaves only the content.

What a ratio buys is that it can contradict a reading. The same tester tried to price a frame with --snapshot, read 1009 milliseconds flat from 0.18 to 9.9 megapixels, concluded that drawing was cheap, and sent that on — the instrument had a start-up floor that swallowed everything it claimed to measure. A second reading would have agreed with the first. The ratio did not: processor against window size at a known wake rate said 125 milliseconds a frame, flatly contradicting it, and that number is what decided the fix.

And what a ratio does not buy, which matters more. It only goes quiet when the faulty instrument sits on both sides of the division. An injector that is not delivering anything sits on neither — it is upstream, and it will hand you two perfectly consistent numbers describing an experiment that never happened. Nothing about the shape of a measurement protects you from that. Only §2½ does.

2⅞. Seen once and not reproduced

Two things were observed exactly once during the verification of this framework, could not be reproduced deliberately, and are recorded here rather than fixed or forgotten. If you meet one in a clean state, it exists; until then it does not.

  • 12.9 % of a processor, held continuously, with 190 wake-ups a second, on a gallery under XWayland. Five configurations built to provoke it again produced nothing. The state at the time was untidy — two galleries running and an editor starting — and the measurement was aimed loosely.
  • A first click on a dropdown producing only a hover, on a freshly opened window; the second click opened it. Three relaunches with that click as the very first event all opened it, and a first click on an ordinary button landed. So it is neither "the first click is lost" nor "the dropdown is broken", and there is nothing else.

Both were reported as unreproduced by the tester who saw them, with the hypotheses they had already eliminated. That is what makes them useful to the next person rather than noise.

3. Reporting

What helps, in order: the exact error text if there is one; otherwise what you see, said plainly — "the text is upside down", "the red buttons are blue", "nothing happens on hover". A screenshot if you can attach one anywhere; the description is enough otherwise.

Say which of the two the snapshot in step 1 was. That one fact decides which half of the codebase to look in.

4. What will not work on your platform, and should not

  • tools/shot.sh photographs a window by its number through macOS's screencapture. There is no portable equivalent; take a screenshot the usual way.
  • --window-id answers an HWND on Windows and an X11 Window on Linux. Both are the system's real handle, and neither is any use to screencapture.