DEV Community

Karim Masmoudi
Karim Masmoudi

Posted on

Shipping a Swift CLI on Windows: what broke

Mudroom runs coding agents like Claude Code, Codex or Gemini CLI in a Linux container, on a copy of your project, and lets you review their changes like a pull request before anything touches your real files. It started as a Mac app. Version 0.2.0 adds a Windows build of the CLI, for x64 and arm64, using Docker Desktop or Podman as the sandbox.

Swift on Windows works better than I expected. Most of the code compiled once the platform-specific parts were split out. What took the time was everything around the compiler: linking, sockets, the file system, PowerShell and CI. Here's what broke, in case you're about to do the same.

1. The C++ headers refuse the compiler

The first build failed before compiling a single Swift file. Swift 6.2 ships Clang 19, and the Visual Studio 2026 C++ headers on GitHub's Windows runners refuse anything older than Clang 20. The headers have an escape hatch:

swift build -Xcxx -D_ALLOW_COMPILER_AND_STL_VERSION_MISMATCH
Enter fullscreen mode Exit fullscreen mode

It's in every Windows build command in CI now, and in the README for anyone building from source.

2. There's no static standard library

On Linux you can pass --static-swift-stdlib and get one binary. On Windows that flag builds, but the exe still imports swiftCore.dll and friends. -Xswiftc -static-stdlib fails outright with "unable to load standard library", because the Windows SDK ships no static Swift libraries.

So the release zip carries the DLLs. The packaging script doesn't use a hand-written list. It runs dumpbin /dependents on the exe, then on every DLL it finds, until nothing new shows up. That gives 15 DLLs on both architectures:

  • the Swift runtime: swiftCore, swiftCRT, swiftWinSDK, swift_Concurrency, swift_StringProcessing, swift_RegexParser, swiftDispatch, dispatch, BlocksRuntime
  • Foundation: Foundation, FoundationEssentials, FoundationInternationalization and _FoundationICU (the ICU data alone is about 36 MB unpacked)
  • the Visual C++ runtime: VCRUNTIME140 and MSVCP140

The zip ends up around 29 MB. To check that it really runs without Swift installed, CI unzips it on a separate runner that never had a Swift toolchain. It confirms there's no swift on PATH, runs the full smoke test, then renames swiftCore.dll and checks that the exe now fails to start with 0xC0000135 (DLL not found). Without that last step, a stray Swift install on the runner could make the test pass for the wrong reason.

3. A socket is not a file descriptor

On macOS and Linux a socket is a file descriptor, and close() closes it. On Windows a socket is a SOCKET handle from Winsock, and handing it to the C runtime's close() crashes the process. Two tests did exactly that. The fix is a small Sock.close that calls closesocket on Windows, and using it everywhere.

4. The 503 that never arrived

Mudroom's network proxy caps how many connections it serves. Over the cap, it writes a 503 and closes the connection. On Windows the client never saw the 503.

If you close a TCP socket while the peer's request is still unread in your receive buffer, Windows sends a reset instead of a normal close, and the reset throws away the 503 the client hasn't read yet. On Windows the proxy now shuts down its sending side, then reads and discards the request for up to 250 ms, so the client gets the 503 before the connection goes away.

5. Atomic writes meet antivirus

Session state is written to a temp file, then moved over the old one with MoveFileExW(..., MOVEFILE_REPLACE_EXISTING). That failed now and then with a sharing violation. Something else had the file open for a moment: Defender scanning the new file, or the search indexer.

The fix is boring. On ERROR_SHARING_VIOLATION or ERROR_LOCK_VIOLATION, wait 50 ms and try again, up to ten times. macOS and Linux keep Foundation's plain .atomic write.

6. Files don't work the way Unix code expects

A diff tool cares about file metadata, and Windows has different metadata:

  • There are no mode bits, so every file reads as 0644. That means chmod +x inside the sandbox doesn't show up in the diff on Windows. It's documented, and nothing to fix.
  • A read-only attribute makes the replace fail with access denied. The applier clears the attribute and tries again (and puts it back if the retry fails too). Undo restores the old file.
  • Junctions (Windows folder links) are reported in the review but never followed, the same way symlinks are handled elsewhere.
  • Long paths: every path handed to the wide-character Win32 calls gets the \\?\ prefix once it nears the old 260-character limit.
  • CRLF line endings have to survive the round trip through the Linux container untouched. A .gitattributes also keeps the agent image files and shell scripts on LF, so Git's checkout on the runner doesn't rewrite them.

7. PowerShell eats --

The smoke test runs mudroom new <project> --agent smoke -- cmd /c exit. When that line went through a PowerShell helper function, the bare -- vanished. PowerShell treats -- as its own end-of-parameters marker. Mudroom then saw cmd /c exit as stray arguments. The fix is to build the arguments as an array and pass the array, so PowerShell doesn't parse them.

8. What CI can and can't test

GitHub's Windows runners can't run Linux containers by default, so for a while nothing tested a real sandbox. It turns out windows-latest runs on hardware with nested virtualization, so WSL 2 and a Podman machine work there. CI now builds the agent image with Podman, runs the network check and a short real session, then diffs and applies the result.

Two things are still open, and I'd rather say so:

  • Locked network mode on Windows. That's the default, where the only way out of the sandbox is an allowlist proxy on the host. It hasn't passed in CI yet: on the runner's rootless Podman machine, the network check hangs. The open-network mode works. I'm working on a fix.
  • Docker Desktop on a real PC. It hasn't been tested yet. The arm64 runner can't run a VM at all, so on arm64 only the zip and the smoke test run in CI.

One Podman detail for anyone else trying this: the runner's Podman machine had no pids cgroup controller, and containers failed with "controller pids is not available". Setting pids_limit = 0 in the machine's containers.conf fixes it.

Try it

scoop bucket add kernel-hunter https://github.com/Kernel-Hunter/scoop-bucket
scoop install mudroom
Enter fullscreen mode Exit fullscreen mode

There are also plain zips on the release page, and a winget package is in review. You'll need Docker Desktop or Podman. If you run it on a real Windows machine, especially with Docker Desktop, I'd like to hear how it went, good or bad: open an issue.

Top comments (0)