Plum

Porting

What a new platform costs, and what is already paid.

Plum began as Linux x86_64 only. This is the record of what that actually meant, what has been fixed, and what is left, written from measurements of this tree rather than from expectations, because the first attempt at guessing (see MAINTENANCE.md, “hand-kept gap lists were wrong three times”) was wrong three times.

Support tiers

The rule the release workflow already enforced, now stated as a promise: a platform is published only once something in CI builds and runs real programs on it. Nothing ships that is merely expected to work.

TierMeaningPlatforms
1Full harness suite green in CI. Binaries published.Linux x86_64, Linux arm64
2bootstrap/platform-smoke green in CI. Binaries published.macOS arm64, macOS x86_64, Windows x86_64
3Expected to work. Untested, unpublished, no promise.

macOS arm64 and Windows x86_64 run on every push. macOS x86_64 runs only on a release tag: Intel Mac runners are scarce enough that the job queued for hours, and a straggler blocks log downloads for the whole run. Publishing an Intel binary still requires it to pass.

Tier 2 is a real step down from tier 1 and the difference is worth knowing: tier 1 runs 61 fixtures under AddressSanitizer with detect_leaks=1, and Plum is refcounted, so a leak there is a miscompile, not untidiness. LeakSanitizer does not exist on Darwin at all. Tier 2 therefore establishes “these programs build and print the right answer” and not “the refcounting is correct”. Correctness is established on Linux and assumed to carry.

What is actually platform-specific

The generated code depends on 54 external symbols:

The compiler emits no LLVM target triple, so clang targets whatever host it runs on. That was already true and is the single biggest reason this port is small rather than large.

Constants are part of the ABI too

setlocale(6, "C.utf8") was emitted straight into the IR. Both halves are glibc-specific: LC_ALL is 6 on glibc and 0 on macOS, where 6 is LC_MESSAGES; and C.utf8 is a glibc locale name macOS does not have. The call therefore set the wrong category to a locale that did not exist, and towupper/towlower silently stopped mapping anything outside ASCII.

The lesson generalises: a libc constant baked into generated IR is as much a portability hazard as a libc symbol, and a quieter one. A sweep of every literal the runtime passes to libc found fseek’s 0/2 (universal in practice) and fopen’s modes (already "rb"/ "wb", so Windows will not rewrite newlines). setlocale was the only real one. Both the category and the locale name now live in compat_shim.c, where the C header supplies them.

The two libc symbols that are not portable

SymbolLinuxmacOSWindows
malloc_usable_sizeglibcmalloc_size_msize
dprintfPOSIXPOSIXabsent

Both are now filled in native_stdlib/compat_shim.c, which is the only file in the project that knows a target’s name.

Why fills rather than renaming the call in the runtime. There is a bootstrap cycle. bootstrap/seed/plum.ll is a checked-in compiler shipped as IR, and that IR already contains the glibc name; on a Mac the seed would fail to link, so there would be no compiler with which to build the compiler that stops emitting the name. Defining the missing symbol breaks the cycle with no seed regeneration. Renaming remains available later as ordinary cleanup.

The seven shims

ShimmacOSWindows
compat_shim.c
io_shim.c✅ stdio only✅ stdio only
os_shim.c✅ Win32 branches
thread_shim.c✅ pthread✅ MinGW winpthreads, -lpthread
dir_shim.c✅ dirent✅ MinGW’s dirent.h, unchanged
process_shim.c✅ fork/waitpidCreateProcess
net_shim.c✅ BSD sockets✅ Winsock, -lws2_32

Every ✅ above is exercised by a CI leg that builds and runs every execution fixture on that platform. dir_shim.c and thread_shim.c needed no Windows code at all, because MinGW-w64 supplies dirent.h and pthreads, which is why neither is on the list of things that had to be written.

Unix commands the compiler shelled out to: fixed

These were never in the shims. They were in the compiler’s own Plum source, so no amount of shim rewriting would have covered them.

CallWasNow
mktemp -d5 sitesOs.temp_dir
rm -rf5 sitesOs.remove_tree
rm -f1 siteOs.remove_file
cp -r1 siteOs.copy_tree
mkdir1 siteOs.make_dir
/proc/self/exe3 sitesOs.self_exe
clang2 sitesunchanged, and intended

/proc/self/exe was the one that mattered before Windows did. It is Linux-only and the language server used it to re-invoke itself for check, query and defs, so the LSP could never have worked on a Mac. native_stdlib/os_shim.c now answers that question with readlink on Linux, _NSGetExecutablePath on Darwin and GetModuleFileNameA on Windows.

Verified by shadowing mktemp, rm, cp and mkdir on PATH with scripts that log and exit 1, then building a project: the old compiler hit them, the new one builds cleanly with zero hits. The only processes plum build now starts are clang, down from four.

plum test and the language server still re-invoke the compiler itself, deliberately: panic_raw aborts rather than returning, so a single-process harness would stop at the first failure, and an in-process type error would take the language server down.

Done

macOS arm64 is verified. The macos-15 CI leg is green: the seed bootstraps a compiler, that compiler builds a compiler, and every execution fixture builds and prints the right answer on Apple Silicon. It took two runs, and the first found the locale bug described above.

The guess about what would break was wrong, which is worth recording. Float formatting was expected to differ between glibc and Apple’s libc and did not; every float fixture passed first time. The failure was character case mapping, from a hardcoded libc constant. Predicting which part of a port breaks is not something this project has been good at.

Left to do

Windows: done

Windows x86_64 went green on 2026-08-25: 44 of 44 programs build and run under MSYS2/MinGW, and the continue-on-error marker came off the CI leg in the same commit. The language server followed the same day, once lsp-smoke could run there, and turned up a bug on its first attempt.

uri_to_path stripped a fixed seven characters from file://. Right on Unix, where the path component’s leading / is the root; and wrong on Windows, where a client sends file:///C:/x/a.plum and gets back /C:/x/a.plum, which opens nothing. path_to_uri had the mirror bug, producing file://C:/x and making C: the URI’s AUTHORITY. Any real Windows editor would have hit this. Nothing but the Windows CI leg exercises that path, so nothing but the Windows CI leg would have found it.

The harness had its own version of the same confusion: it handed the compiler MSYS paths. Under MSYS2 an /tmp/... path is translated when passed as an ARGUMENT and not when it is buried inside a JSON string, which is where an LSP session puts it, so platform-smoke was never affected while lsp-smoke could not open a single file. Every path in that harness is now converted with cygpath -m up front.

The toolchain reasoning, kept because it is the decision everything else followed from:

Under the MinGW route, in order:

  1. An OS shim replacing the 16 shell-out sites. Done. Its Windows branches are written but have never been compiled by a Windows toolchain, so treat them as a starting point rather than as working code.

  2. Rewrite process_shim.c without fork. Done, unverified. CreateProcess with the temp-file capture kept verbatim, because the pipe-deadlock reasoning behind it is not platform-specific. _spawnvp was considered and rejected: the CRT joins argv with plain spaces and adds no quoting, so it has the same problem with an extra layer over it.

  3. Add a windows-latest CI leg running platform-smoke under MSYS2. Done.

  4. Get that leg green. Done, 2026-08-25, 43 of 43. It took three rounds, and every one was worth more than the analysis that would have replaced it: a second fork site nobody had read, a sys/socket.h left outside a guard, and CRLF output hidden behind a CRLF checkout.

  5. Rewrite net_shim.c against Winsock. Done.

    This was previously described here as “not on the critical path”. That was wrong. write_shims writes every embedded shim into each build and hands them all to clang, so net_shim.c is compiled into every plum build whether the program opens a socket or not. Nothing would have built on Windows until it compiled.

  6. Add the release matrix entry. Done. release.yml builds and publishes plum-<version>-x86_64-windows.tar.gz.

What was verified before Windows CI could run it

All of the below was checked on Linux while the Windows leg was still red. It is recorded because it is the part that made three CI rounds enough instead of ten:

Linux arm64: tier 1 as of 2026-08-26

A linux-arm64 job runs on ubuntu-24.04-arm. It is deliberately the HEAVIEST of the non-reference legs: bootstrap-check, the full corpus under AddressSanitizer with detect_leaks=1, platform-smoke, lsp-smoke and the properties.

That is the opposite of how macOS and Windows are treated, and for a reason. Those platforms cannot run leak checking at all, because LeakSanitizer does not exist on Darwin. This one is Linux, so it can. A new ARCHITECTURE is exactly where a refcounting or alignment miscompile would appear, and a leak in a refcounted language is a miscompile rather than untidiness. Checking only that programs print the right bytes would miss the class of bug most worth looking for here.

bootstrap/cross-check also compile-checks aarch64-linux-gnu from a Linux x86_64 box, which is free and catches the shim-portability class without waiting for CI.

It went green on the first run: bootstrap-check, 64 corpus fixtures under ASan, 102 goldens, platform-smoke, lsp-smoke and the properties, in 2m30s, so it earned its release job by the documented rule and has one. install.sh accepts arm64-linux too; it had been refusing it with a build-from-source message, which would have been wrong the moment the first binary was published.

Linux arm64: the original note

Nearly free once macOS arm64 is green, since that proves the compiler produces correct code for the architecture. Mostly a runner change.

Cross-compiling from Linux

zig cc cross-compiles every shim, and links the whole compiler, for macOS arm64, macOS x86_64 and Windows x86_64, from a Linux box, with no Xcode SDK and no Windows toolchain, in about two seconds. Zig vendors the libc headers and link stubs for all three. bootstrap/cross-check does exactly this and skips cleanly where zig is absent.

It does not replace a platform CI leg, and must not be treated as one. It proves that code compiles and links. Nothing it produces is ever executed, so it says nothing about behaviour: not the locale bug, not the exponent padding, not whether CreateProcess actually starts clang. Every bug this port has hit, except the compile errors, would have sailed straight through it. The tier rule stands: a platform is published only once something in CI runs real programs on it.

Nor should release artifacts be built this way, though they could be. A cross-linked binary is one no machine has ever executed, produced by a different toolchain than the one the tests ran under. The current arrangement builds each platform’s binary on that platform, right after the harness passed there, which is the property worth keeping.

Since 2026-08-27 the harness also drives plum build --target on a real program, and one leg of that DOES run: the aarch64 binary executes under qemu. That is a stronger check than the compile-only ones above, but it does not move the tier rule either. One architecture under emulation is not macOS, and it is not Windows.

The rule against cross-building release artifacts matters more now that users can cross-build, not less. --target exists so a user can ship their own program from one machine; Plum’s own binaries are still built on the platform whose test suite just passed, which is the property worth keeping.

What it is worth: closing the compile-error feedback loop from a CI round trip down to a second. Three of this port’s failures were compile errors of one shape: a POSIX header or call left outside a platform guard, invisible on Linux where the guard is inert.

Cost, and what the real cost was

GitHub Actions is free for public repositories, this one included, on every runner size. There was no bill to reduce.

The real cost was latency, and only on Intel macOS: macos-15 (arm64) finishes in under a minute, while macos-13 sat queued for hours. Worse than being slow, a straggling job keeps the whole run marked in-progress, and GitHub will not serve any job’s logs until the run completes, so one runner blocked the diagnosis of every other leg.

And it was never going to arrive. The job eventually ended at 24h0m1s, which is GitHub’s job timeout, not a runner. macos-13 had been retired: actions/runner-images publishes only macos-15 and macos-26, each with an x86_64 and an arm64 variant. A runs-on naming an image that no longer exists does not fail fast; it waits a full day and then dies.

Two changes came out of that. Intel macOS is now macos-15-intel, the x86_64 image of a current OS, which does exist. And every job in both workflows sets timeout-minutes, so a runner that never arrives costs minutes rather than a day. Intel macOS remains release-only: publishing an Intel binary requires it to pass, and a tag is a place where waiting is acceptable while a push is not.

The bug that was hidden by another bug

Windows output was CRLF all along. Microsoft’s CRT opens stdout in text mode, so every \n a Plum program wrote became \r\n.

It went unnoticed because a second problem cancelled it out. Git for Windows defaults to core.autocrlf=true, so the checked-in expected.txt recordings were also checked out as CRLF, and two wrong things compared equal. 40 of 43 fixtures passed for the wrong reason.

Adding .gitattributes with eol=lf fixed the checkout and removed the cancellation, and 38 fixtures failed at once. The apparent regression was the port getting more honest, not less.

Two things made it unusually hard to see:

The fix is plum_set_binary_stdio in compat_shim.c, called at startup. It is right independently of this bug: Plum emits UTF-8 bytes, its corpus compares output byte for byte, the same program must print the same bytes everywhere, and plum emit-llvm writes IR to stdout — which text mode was corrupting too.

The lesson worth keeping: a green test can mean two errors that cancel. This one survived a full CI run looking healthy.

What the second CI run found

macOS arm64 and Linux both went green. Windows got much further: the whole shim set compiled and linked under MinGW, and from-seed produced a working compiler binary. It then failed building anything, on this:

net_shim.c:48:10: fatal error: 'sys/socket.h' file not found

Self-inflicted, and instructive. The Winsock port moved the socket headers into a platform guard, but only from netinet/in.h down. sys/types.h and sys/socket.h sat two lines above the edited region and stayed outside it. The port looked complete and compiled fine on Linux, where the guard is inert.

bootstrap/check-shims now rejects a non-portable header outside a platform guard, so this cannot recur quietly. dirent.h, pthread.h and unistd.h are deliberately not on its list: MinGW-w64 provides all three, which the CI leg proved by linking three shims that use them.

What the first CI run found

The Windows leg earned its place immediately, and so did the macOS one.

The macOS bootstrap itself, seed to compiler to compiler, passed on arm64 on the first attempt.

Generated from PORTING.md in the repository.