The Journey of Building PatLang, Continued Once Again: Giving It a Window
For new readers
This is one instalment in an ongoing, chronological diary of building PatLang, written up in numbered "Acts" as the project actually happened, warts included. This instalment covers canvas_host: a new native Windows program that opens a real GUI window and embeds PatLang's own interactive console engine directly inside it — no subprocess, no terminal emulator, one compiled binary talking straight to Win32. Getting there meant teaching the x64 backend things it had never needed before: how to open a window without breaking its own function-calling convention, how to run real OS threads without corrupting its own heap, and how to survive several of Windows' own well-hidden behavioural surprises.
A direct continuation of the previous instalment (Acts XLVI-L: a stretch of bugs that turned out to be fixes that never really took) — split into its own page for the same reason every earlier page split off from the one before it. Where that arc was about going back over old ground and finding it not as solid as believed, this one is about new ground entirely: PatLang's x64 backend had, until now, only ever needed to talk to files, sockets, and child processes. Giving it a real window meant confronting an entire category of problem — a foreign calling convention arriving from the operating system itself, real concurrency, and a Windows API that keeps a few surprises for anyone who assumes it behaves the way its documentation implies.
Act LI: a WNDPROC PatLang can't actually be
Windows expects a window procedure — the function it calls directly, synchronously, from inside its own message-dispatch machinery — to follow the ordinary Win64 ABI: four arguments in rcx/rdx/r8/r9, callee pops nothing. PatLang-compiled functions don't work that way; they use their own stack-pushed-argument, caller-cleans convention throughout, and no register is guaranteed to survive a call from one PatLang function into another. Registering a compiled PatLang function as lpfnWndProc directly was never going to work.
The fix sidesteps the mismatch rather than bridging it: a single, fixed trampoline, emitted once as literal assembly text rather than compiled from PatLang source, that does nothing but tail-jump straight to DefWindowProcW:
patlang_wndproc_trampoline:
jmp DefWindowProcW
Since Win32 already places (hwnd, msg, wParam, lParam) in exactly the registers DefWindowProcW itself expects, a tail-jump needs no prologue, no stack frame, nothing PatLang-shaped at all. Every piece of real handling — keyboard input, paint, close — happens later, from an ordinary PatLang-driven loop calling PeekMessageW and inspecting whatever it finds, which is just an ordinary call PatLang already knows how to make. Win32 never has to call into compiled PatLang logic at all; PatLang only ever calls out to Win32, on its own terms, whenever it feels like polling. It's a design that avoids the entire class of problem rather than solving it — the same instinct as the tail-jump itself: the cheapest fix for "these two things don't speak the same language" is often "don't make them talk directly."
Lesson: when an external system insists on calling back into your own code using a calling convention your language doesn't (and can't easily) support, look for a way to make the callback do nothing at all and move the real work to somewhere you're already in control of the call.
Act LII: a non-blocking read, learned the hard way once already
Hosting a real external program's terminal output (ConPTY, Windows' pseudo-console API) needed the same lesson this project had already paid for once, in an entirely different subsystem: never call a blocking read on a pipe that might currently be empty from inside a loop that also has other things to do. ReadFile on an empty ConPTY output pipe blocks until data arrives, which would freeze the entire window — including its own message pump — the moment a hosted program went quiet even briefly. PeekNamedPipe first, exactly mirroring why the window's own message loop uses PeekMessageW rather than GetMessageW, avoids it entirely: check whether there's anything to read before ever asking to read it.
Before pointing this at anything as complex as a real interactive editor, the plumbing was proven against the simplest possible case first — cmd /c echo hello && exit through the real ConPTY pipe, byte-compared against the expected literal output. Only once that passed cleanly did testing move on to confirming that vim would at least launch and accept keystrokes as a raw, unprocessed byte dump (correct rendering came later — the VT100/ANSI interpreter this raw dump eventually feeds is written and tested, though not yet wired into canvas_host itself).
Lesson: a lesson learned once in one subsystem is worth actively checking for in the next subsystem that touches the same underlying primitive (a pipe, in this case) rather than assuming it won't recur just because the code is new. And verify plumbing against the smallest possible deterministic case before pointing it at something as complicated and stateful as a real interactive program.
Act LIII: embedding the console, not launching a copy of it
The point of canvas_host was never "a terminal emulator that happens to run PatLang" — it's the existing, already-working console_step engine (the same interactive core an earlier arc built to be embeddable by design, not just usable standalone) compiled directly into the same binary as the window itself, with zero subprocess and zero IPC between them. console_step takes a line of input and a dispatch table and returns a structured result; canvas_host's job reduces to feeding it keystrokes and painting whatever comes back as scrolling text on its own DIB-backed canvas surface. Because console_step is already pure with respect to terminal I/O — state passed in and out, no assumptions about stdin/stdout existing — it needed no changes at all to be driven from a Win32 message loop instead of a blocking stdin read.
This is also where canvas_host got its first real capabilities beyond plain text: native image rendering straight onto the canvas (reusing the project's own BMP/PNG decoders unchanged, bypassing Sixel entirely since there's no terminal emulator in the loop to encode for), and, this session, two new builtins that call out to the network from inside the embedded console — send <host[:port]> <command>, relaying a command to another running instance, and ask <model> <question>, a direct line to a locally-running Ollama model. Both started life as synchronous calls, which is where Act LIV picks up.
Lesson: designing a core engine to be embeddable — a pure step function, state threaded explicitly, no hidden assumption about what's driving it — pays off precisely when a completely different host (a GUI event loop, instead of a blocking terminal read) shows up later wanting to drive the exact same logic.
Act LIV: real threads, and a race that had been waiting to happen
A synchronous ask call blocks the entire window for however long a language model takes to answer — unacceptable for something meant to feel like a normal, responsive console. The direct prompt that opened this was a reminder rather than a request: "Patlang has builtin threading and event handling..." Asked how far to take it — a hand-rolled poll state machine, real OS threads, or porting the project's existing cooperative-fiber mechanism to the x64 backend — the answer was direct: "2 and 3... both should be there." Real threads were built and wired in fully this session; the fiber port is explicitly still to come, not silently dropped.
thread_spawn reuses PatLang's own existing closure representation (a code address plus captured values, the exact shape CallValue already knows how to invoke) rather than inventing any new way to name "the function to run on a new thread" — a real CreateThread, a small trampoline that unpacks the closure and calls into it, and a context block the spawning thread can poll for completion without ever blocking on it.
Getting the trampoline right meant re-deriving, from the compiler's own generated code rather than from memory, exactly which convention PatLang-compiled functions use: caller-cleans, not callee-pops — the trampoline's first draft tried to hold the thread's own context pointer in a register across the call into the spawned closure, which is unsafe for the same reason nothing else in this backend ever assumes a register survives a PatLang function call. Fixed by keeping the context pointer on the trampoline's own stack frame instead, where a call genuinely can't touch it.
Adding real concurrency also meant confronting shared mutable state that every previous single-threaded caller had gotten away with treating carelessly. The heap allocator itself — the thing every string, every list, every closure allocation in the entire language ultimately goes through — claimed its space with a plain read-then-write: read how much of the heap is used, then write back the new total. Two threads calling it at the same moment can both read the same starting point and both get handed the identical address range, corrupting whatever ends up written there afterward — not a crash, silent data corruption, and not confined to code that's deliberately concurrent, since ANY heap allocation anywhere goes through this one function. Fixed with a genuine atomic fetch-and-add instead of the separate read and write, filed and closed as its own tracked issue once verified. A second shared buffer used by several existing file/network primitives got the same treatment via a lazily-initialized, properly-locked critical section.
Lesson: adding real concurrency to a system that was only ever exercised single-threaded doesn't just risk new bugs in new code — it exposes every piece of existing shared state that was already unsafe and simply never had a second thread around to prove it.
Act LV: a resize message that never arrives where you're looking for it
The direct request was plain: "Can you do copy paste and resize please." Resize looked, at first, like it should be the easier half. Handling WM_SIZE the same way every other message was already handled — checking for it inside the same PeekMessageW-based polling loop used for everything else — compiled cleanly, ran without crashing, and did precisely nothing. A live test confirmed the window's own dimensions genuinely had changed (queried directly via GetWindowRect), but the canvas content stayed pinned at its original size in the corner, the rest of the enlarged window showing plain black, and the debug print inside the resize-handling branch never once fired.
The explanation is a genuine, easy-to-miss fact about Win32 rather than a bug in the polling logic: WM_SIZE is delivered via SendMessage — a direct, synchronous call straight into the window procedure, made from inside whatever triggered the resize — never posted to the queue PeekMessageW actually drains. The only code in the entire program that could ever see it was the trampoline from Act LI, which until this point had been a bare tail-jump specifically because nothing had needed it to notice anything. It now checks the incoming message code before its final jump, and on a resize, stashes the new width and height into a couple of global slots for the main loop to notice and act on next time it polls — using only the registers Win32's own calling convention doesn't already need for the four real WNDPROC arguments, so the unconditional tail-jump at the end still sees exactly what Windows itself would have sent. Filed as its own issue once confirmed, alongside the DIB surface reallocation logic (reusing the existing device context rather than leaking a new one on every resize) that actually grows the canvas once the new size is known.
Lesson: "it compiled, it ran, and nothing visibly happened" is a different failure mode from a crash, and deserves the same suspicion — sometimes the code you wrote is correct for messages that never actually reach it, and the fix is finding out where the message really goes, not staring harder at the code that's waiting for it.
Act LVI: clipboard bugs, an async redesign, and a stray pid
Clipboard support (the other half of the same request) hit two real, separate crashes, each caught by a dedicated isolation probe rather than by reading the assembly and guessing. Copying crashed first: the calling convention this file already used consistently elsewhere — two stack-pushed arguments read back in a specific, established order — had the address and length read backwards, turning the string's own length into a wild pointer the moment the copy loop dereferenced it. Pasting crashed next, after the first fix: the new string buffer's address was held in r11 across two further real Windows API calls, and r11, unlike the four registers (r12-r15) this file's own established convention already knew to treat as safe across a genuine WinAPI call, is not one of them — a distinction between "safe across a call to another PatLang function" (nothing is) and "safe across a call to a real, trusted external function" (only a specific, smaller set of registers) that this codebase has now gotten wrong twice in two different subsystems. Both fixed, both verified against the real system clipboard, and both written up as one combined issue.
The send/ask builtins from Act LIII were redesigned around the new threading infrastructure at the same time: each now spawns its network I/O on a real thread and returns immediately with a "waiting..." acknowledgement, while the main loop polls once per tick for a result to append to the scrollback — verified live by typing an entirely different command while a slow ask was still in flight and watching it run immediately rather than queue behind the first. cls and wc rounded out the session's new builtins, the latter checked byte-for-byte against a reference wc's own line/word/character counts on the same file.
One further bug surfaced sideways, from an entirely different part of the same session's work: the per-function object cache used while natively compiling large programs (canvas_host itself, being a large one, exercised it heavily) spawns a batch of short-lived nasm.exe processes, and under a large enough batch, wait() started reproducibly failing with "unknown or already-reaped pid." The cause: child-process tracking was keyed by the real OS process id, and Windows can and does reuse a just-freed pid for a brand-new process before a stale map entry for the old one has been removed — the exact same class of bug this project had already fixed once before for network connection ids, recurring in a different table that had never been updated to match. Fixed the same way — a synthetic, monotonically-increasing id instead of the recycled OS one — in both the places this project keeps a copy of the same logic, and filed once verified.
Lesson: a hard-won distinction ("this specific, small set of registers survives a call; everything else doesn't") is easy to state once and still get wrong a second time in a different function, because the rule lives in a comment and a habit, not in anything the compiler itself enforces — worth writing down explicitly, and worth specifically re-checking whenever new code claims to preserve a register across any kind of external call. And a fix for "the same recycled-id problem, in a different table" is worth actively looking for elsewhere the moment you recognise the shape of it once.
If you're building something similar: the short version
- When a foreign calling convention insists on calling back into your code, consider making the callback do as little as possible and moving the real logic to a call your own code initiates instead — a tail-jump trampoline avoided an entire class of ABI-bridging work outright.
- A lesson paid for once in one subsystem (never block-read a pipe that might be empty) is worth checking for by name in the next subsystem that touches the same kind of primitive, rather than trusting that new code in a new file won't repeat it.
- Design a core engine's own step function to be pure and embeddable early, and a completely different host built much later (a GUI event loop instead of a terminal's stdin loop) can drive it with no changes at all.
- Adding real concurrency to code that was only ever exercised single-threaded surfaces every piece of already-unsafe shared state at once — audit for it deliberately rather than waiting for the corruption to show up somewhere confusing.
- "It compiled and ran, and nothing happened" deserves the same suspicion as a crash. Sometimes the handler is correct and simply never sees the message, because the message never arrives where you're listening for it.
- A hard-won register-safety rule is easy to get wrong a second time if it lives only in a comment and a habit rather than anywhere the compiler enforces it — and a bug's shape, once recognised, is worth actively searching for in every other place the same pattern was used.
See also
The Journey of Building PatLang (Acts I-VI), the second instalment (Acts VII-XIV), the third (Acts XV-XXIII), the fourth (Acts XXIV-XXVIII), the fifth (Acts XXIX-XXXV), the sixth (Acts XXXVI-XLV), and the seventh (Acts XLVI-L) for where this page picks up from. The project's GitHub issue tracker carries the full record of this arc's four tracked bugs: the non-atomic heap allocator race, the WM_SIZE delivery surprise, the clipboard argument-order and volatile-register bugs, and the recycled-pid race in child-process tracking. PatLang Fibers covers the cooperative-coroutine mechanism named as still-pending work for the x64 backend in Act LIV. Capabilities & Honest Limitations covers what the wider language does and doesn't do, independent of this one program.