Featured image of post Why my photo app won't build for Windows

Why my photo app won't build for Windows

I was wiring up the release build for krites, the wedding-photo culler I’ve been building for my wife, and I went to add Windows to the list of targets. It felt like the responsible thing. You write software, you ship it for the three big desktops, that’s just tidy. So I added windows to the build config, kicked off a cross-compile from my Linux box, and watched it fall over before it had really started.

Now, the sensible engineer’s instinct at that point is to roll up the sleeves and fix it. Mine was too, for about five minutes… then I read the actual error, thought about who was ever going to run this thing, and did the opposite. I deleted Windows and moved on.

The binary with no C in it

To explain why the build died, I have to back up to a decision that felt clever at the time.

krites does some machine learning. Not for the culling itself, which is deliberately model-free arithmetic, but for the finer judgements: is a subject mid-blink, are they looking at the camera. Those run on ONNX Runtime, Microsoft’s inference engine, which is a big lump of C++ that lives in a shared library.

The usual way to call a C library from Go is CGO. It works, but it comes with a tax: the moment you switch CGO on, building your program means having a C compiler and the right headers for every platform you target. Cross-compiling from one machine to all the others, which is the whole convenience of Go, stops being free. You end up maintaining a little zoo of toolchains just to produce binaries.

So I went the other way. There’s a lovely library called purego that lets you call into a C shared library with CGO switched off entirely. It does the dynamic loading itself, in pure Go, and hands you back function pointers. The ONNX binding krites uses, onnxruntime-purego, is built on it. The upshot is a binary compiled with CGO_ENABLED=0: no C compiler in the build, no headers, cross-compile to anything from my laptop. The provisioner that fetches the runtime library is ordinary Go, net/http and archive/tar, nothing exotic.

Pure Go, no CGO, builds everywhere. You can probably see where this is going…

The trick that doesn’t cross the Channel

Here’s the bit I hadn’t thought hard enough about. How does purego load a shared library without CGO? It calls dlopen.

dlopen is a Unix system call. It’s how a running program reaches out and pulls in a .so or a .dylib at runtime, the thing CGO normally does for you behind the scenes. purego just does it directly. And that’s the whole trick: no C compiler needed, because the loading happens through a system call that’s already sitting there in the OS.

Except it isn’t there on Windows. Windows has its own mechanism for the same job, LoadLibrary, with a completely different shape. So purego’s dlopen and the flags that go with it (RTLD_NOW, RTLD_GLOBAL, if you’ve ever squinted at a Unix man page) simply don’t exist when you compile for Windows. They’re guarded behind a build constraint that reads, in effect, “Linux or macOS or the other BSDs, and nothing else”. Target Windows and those symbols vanish. The binding calls a function that, on that platform, was never compiled in.

My favourite detail is that the binding looks like it supports Windows right up until the moment it can’t. Its library-name helper has a branch for it:

switch runtime.GOOS {
case "darwin":
    return "libonnxruntime.dylib"
case "linux":
    return "libonnxruntime.so"
case "windows":
    return "onnxruntime.dll"
}

It knows the DLL is called onnxruntime.dll. It’s dressed for the party. Then two lines later it goes to actually open the thing with dlopen, the linker looks for a symbol that isn’t there for this target, and the whole build stops. The Windows case is code that can never run, because it can never compile.

So this was never a bug waiting for me to be clever enough to fix. A CGO-free binding buys you a great deal, but it buys it by being Unix. The portability I was so chuffed with was POSIX portability, and Windows was never inside the fence. I’d picked the option that looked like it built everywhere and it still had a border on it.

Knowing where the software actually lives

Which left me with the interesting question, and it wasn’t “how do I make Windows work”. It was “does anyone need it to”.

krites is for Hailey, my wife. She’s a photographer, and she works on a Mac, because that’s what photographers do. The whole plan for the ML side leans into that: the fast path is Apple’s own CoreML running on the M-series chip, the dylib bundled right beside the app. macOS isn’t a platform I tolerate here, it’s the one I’m building for. Linux is the other target, and it earns its place for a different reason, it’s where the CI runs and where a headless batch job would live. Neither of those is Windows. Windows was on the list purely because three feels like a rounder number than two.

So the fix was a diff that removed a line. The release config now lists linux and darwin and stops there, with a comment for the next person (me, in six months, having forgotten all of this) explaining why the obvious third entry isn’t obvious at all.

I could have chased it. There are ways: shim the loader, find a binding that speaks LoadLibrary, turn CGO back on for the Windows build alone and hand-feed it a toolchain. Every one of them is real work in service of a platform my one and only user will never open the app on. That’s effort spent making a number rounder.

A build target isn’t a box you tick to look thorough. It’s a claim about where your software actually lives, and krites lives on a photographer’s Mac. Once I’d said that out loud, the Windows build stopped being a problem to solve. It was a line I got to delete.

Built with Hugo
Theme Stack designed by Jimmy