Skip to content

feat(nix,ci): make the Windows archives reachable, and publish a linkable MinGW-cross variant - #56

Draft
dlipicar wants to merge 3 commits into
logos-blockchain:mainfrom
dlipicar:feat/expose-windows-cross
Draft

dlipicar wants to merge 3 commits into
logos-blockchain:mainfrom
dlipicar:feat/expose-windows-cross

Conversation

@dlipicar

@dlipicar dlipicar commented Sep 18, 2026 •

Copy link
Copy Markdown

Three commits. The first makes the existing Windows archives reachable; the
second publishes a variant that is actually linkable by a cross toolchain; the
third pins the toolchain that variant is built with.

1. packages.x86_64-windows has never worked

$ nix eval .#packages.x86_64-windows.default
error: attribute 'x86_64-windows' missing

mkCircuits starts from nixpkgs.legacyPackages.${system} and nixpkgs has no
Windows package set. So although this repo publishes Windows archives, and
circuits-nix-hashes.json has carried an x86_64-windows entry since 0.3.2, no
flake consumer could reach them.

mkCircuitsFor { buildSystem, target } splits the system nix builds on from
the one whose archives we fetch; targetOs/targetArch derive the asset name
from the target string rather than stdenv. Windows is published under each
native platform as circuits-windows-x86_64. (meta.platforms is checked
against the build system, so it stays buildSystem — setting it to the target
makes the package refuse to build anywhere.)

2. …but those archives cannot be linked by anyone else

A direct g++ link of the published pol.lib with a nixpkgs MinGW toolchain —
no Rust, no nix, just the archive — leaves 981 undefined references. Two
causes, both in the library rule, neither a GCC-version ABI difference:

  1. ld -r on PE merges the COMDAT sections GCC emits for templates, and the
    merged object then fails with
    relocation truncated to fit: IMAGE_REL_AMD64_REL32.
  2. objcopy --keep-global-symbol localizes everything else, which on PE
    includes those COMDAT symbols — std::__cxx11::to_string,
    std::operator+(basic_string&&, basic_string&&), ~20 nlohmann/json
    instantiations. Once localized, the consumer's own copies cannot satisfy
    them.

Localizing per object instead is not a fix: it breaks references between the
objects (ffi.o then cannot see circom_main in main.o). That is presumably
why the merge is there in the first place.

windows-cross-lib

Rather than change the mechanism the native targets use, this adds a second one.
isolate-symbols.sh renames every internal global to __<project>_priv_<sym>,
applying one map to every object:

  • intra-archive references still resolve, because the rename is consistent
  • nothing collides across circuits, because the prefix is per-circuit
  • COMDAT symbols are left alone, because they are meant to be shared

No ld -r, so no truncation. OS=windows-cross also keeps the lib<name>.a
naming GNU ld resolves from -l<name> — the MSYS2 bundle's pol.lib does not.

Measured

Cross-built pol on Linux from this Makefile target and this script:

internal symbols renamed 339
kept (2 public + COMDAT) 73
public entry points exported 2
leaked Fr_ / Circom globals 0
link undefined 0, truncated 0 → pei-x86-64

The resulting binary runs on real Windows (10.0.26200). Control: the same
link against the published pol.lib gives 981 undefined references.

What the job publishes

build-windows-cross on ubuntu-latest produces
...-windows-x86_64-gnu.tar.gz, with a gate asserting each archive is PE,
exports exactly its two entry points, and leaks no internal globals. It bundles
libmman.a as part of the contract — the circuit objects call mmap/munmap
and, unlike the MSYS2 bundle, nothing else here supplies them. Debian's MinGW
defaults to the win32 threads model (no std::thread/std::mutex), so the job
selects the -posix alternatives.

The flake gains x86_64-windows-gnu. With a hash present the attribute appears
and resolves to logos-blockchain-circuits-v<ver>-windows-x86_64-gnu.tar.gz;
without one it is absent, so nothing changes until a release publishes it.

3. The variant's toolchain is pinned

Archives built with one mingw-w64 do not link with another. Measured downstream:
cross-built with GCC 15.3 and linked by a consumer on GCC 14.3, wallet-ffi
stops on one symbol, undefined reference to fstat64i32 — the generated
main.cpp calls fstat, which GCC 14.3's headers route to _fstat64i32 and
15.3's to fstat64i32, and each CRT provides only its own.

Consumers in this stack are nix builds following logos-nix, so the variant is
now built with that:

  • a logos-nix input, used only here — nixpkgs is untouched, so native
    outputs are byte-identical (default is still wzi7bxa2…-0.5.7). The lock
    pins 2ab5416a, the rev logos-execution-zone locks today; relock together.
  • devShells.x86_64-linux.windows-cross puts that MinGW on PATH with a
    static GMP from the same package set.
  • build-windows-cross.sh / verify-windows-cross.sh hold the build and
    the gate, so CI and a developer run the same nix develop .#windows-cross
    command. libmman is compiled there too, with the archives' own toolchain,
    rather than shipped prebuilt — the prebuilt one drifted the same way.
  • the job sets up Nix with logos-co/setup-nix-cache-action@v1.
  • the bundle carries TOOLCHAIN (x86_64-w64-mingw32-gcc 14.3.0; logos-nix <rev>), so a consumer can check before it meets an undefined symbol.

Verified end to end: the committed scripts, run exactly as the job runs
them, build and pass the gate for all four circuits; and with circuit and
rapidsnark archives from this pinned toolchain,
execution-zone#900's
wallet-windows-x86_64 builds through nix to wallet_ffi.dll (pei-x86-64,
58 exported wallet_ffi_* symbols). actionlint is unchanged at 11 pre-existing infos.

Publishing to the Logos cache needs this repo listed in infra-ci's attic.yml;
until then the action pulls from the cache and skips publishing rather than failing.

Not verified

The new job has not run — it needs a release build to exercise it, and a PR
cannot trigger one. What is verified is everything it does, reproduced by hand
on a Linux builder using the exact Makefile target and script committed here.

prover.exe / verifier.exe are deliberately omitted from this variant: it
exists for linking, not for running the tools. Say the word if you want them and
I will add the cross build for those too.

packages.x86_64-linux.default is /nix/store/wzi7bxa2...-0.5.7,
byte-identical to upstream, and actionlint reports the same 11 pre-existing
shellcheck infos before and after.

Context

Part of getting logos-delivery-module onto Windows:
delivery_module → liblogos_rln_module → liblogos_lez_rln_module → lez_core → wallet_ffi. wallet-ffi already compiles for x86_64-pc-windows-gnu
(execution-zone#900,
rapidsnark#1);
these archives are the last thing it needs to link.

🤖 Generated with Claude Code

dlipicar and others added 2 commits September 17, 2026 22:25
`packages.x86_64-windows` has never worked:

    $ nix eval .#packages.x86_64-windows.default
    error: attribute 'x86_64-windows' missing

`mkCircuits` starts from `nixpkgs.legacyPackages.${system}`, and nixpkgs has no
Windows package set -- nix does not run there. So although this repo builds and
publishes Windows archives (and `circuits-nix-hashes.json` has carried an
x86_64-windows entry since 0.3.2), no flake consumer could reach them.

The derivation is `fetchurl` plus an unpack. It needs no toolchain for the
target at all, so the fix is to stop conflating the system nix builds *on* with
the system whose archives we fetch:

* `mkCircuitsFor { buildSystem, target }` -- `buildSystem` instantiates nixpkgs,
  `target` selects the asset and its hash.
* `targetOs` / `targetArch` derive the asset name from the target string rather
  than from `stdenv`, so a target that cannot be instantiated can still be named.
* `mkCircuits system` stays as the native shorthand, so nothing else moves.
* Windows is published under every native platform as
  `circuits-windows-x86_64`, the shape zerokit and logos-delivery already use
  and the one logos-module-builder's `externalLibInputs.<n>.systems.<target>`
  override expects.
* `x86_64-windows` leaves `systems`, which is the list nix instantiates. It is a
  cross target now, in `crossTargets`.

`meta.platforms` is the build platform, not the target: nix checks it against
the system it builds on, and setting it to the target makes the package refuse
to build anywhere.

Verified on x86_64-linux:

    packages.x86_64-linux  ->  [ "circuits" "circuits-windows-x86_64" "default" ]

    .#packages.x86_64-linux.circuits-windows-x86_64
      pol.lib / poq.lib / poc.lib / signature.lib / libgmp.a  ->  all pe-x86-64

    .#packages.x86_64-linux.default
      /nix/store/wzi7bxa2...-logos-blockchain-circuits-0.5.7

That last path is byte-identical to what upstream produces today, so the native
packages are untouched. aarch64-darwin evaluates too.

Consumers should know the published Windows archives are not self-contained:
they reference `mmap`, `munmap` and out-of-line libstdc++ symbols such as
`std::__cxx11::to_string(int)`, which the MSYS2 MINGW64 toolchain in
`build-windows` supplies via `.github/resources/prover/windows.*`. Linking them
with a different MinGW (a nixpkgs cross toolchain, say) leaves those undefined.
That is worth a follow-up; this change only makes the artifacts reachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The archives `build-windows` publishes cannot be linked by any toolchain other
than the MSYS2 one that produced them. Measured against a nixpkgs MinGW cross
toolchain, a direct `g++` link of `pol.lib` (no Rust, no nix) leaves 981
undefined references.

Two causes, both in the library rule, neither version-related:

1. `ld -r` on PE merges the COMDAT sections GCC emits for templates, and the
   merged object then fails to link with
   `relocation truncated to fit: IMAGE_REL_AMD64_REL32`.
2. `objcopy --keep-global-symbol` localizes everything it is not told to keep,
   which on PE includes those same COMDAT symbols -- `std::__cxx11::to_string`,
   `std::operator+(basic_string&&, basic_string&&)`, ~20 nlohmann/json
   instantiations. Localized, the consumer's own copies no longer satisfy them.

Localizing per object instead is not a fix either: it breaks the references
between the objects (ffi.o then cannot see `circom_main` in main.o).

## windows-cross-lib

So this adds a second isolation mechanism rather than changing the one the
native targets use. `isolate-symbols.sh` renames each internal global to
`__<project>_priv_<sym>`, applying the same map to every object: intra-archive
references still resolve because the rename is consistent, nothing collides
across circuits because the prefix is per-circuit, and COMDAT symbols are left
alone precisely because they are meant to be shared with the consumer. No
`ld -r`, so no truncation.

`OS=windows-cross` also keeps the `lib<name>.a` naming GNU `ld` resolves from
`-l<name>`; the MSYS2 bundle's `pol.lib` does not.

## Verified end to end

Cross-built `pol` on Linux with a MinGW toolchain, from this Makefile target and
this script:

    renamed 339 internal symbols, kept 73 (2 public + 71 COMDAT)
    public entry points : 2
    leaked Fr_/Circom   : 0
    link: undefined 0, truncated 0  ->  pei-x86-64

and the resulting binary runs on real Windows (10.0.26200). The same link
against the published `pol.lib` is the control: 981 undefined references.

## The rest

* `build-windows-cross` on ubuntu-latest publishes
  `...-windows-x86_64-gnu.tar.gz`, with a gate gating asserting each archive is
  PE, exports exactly its 2 entry points, and leaks no `Fr_`/`Circom` globals.
* It bundles `libmman.a` as part of the contract: the circuit objects call
  `mmap`/`munmap`, and unlike the MSYS2 bundle nothing else in the tarball
  supplies them.
* Debian's MinGW defaults to the win32 threads model, which has no
  `std::thread` or `std::mutex`; the job selects the `-posix` alternatives.
* The flake gains `x86_64-windows-gnu`. With a hash present the attribute
  appears and resolves to
  `logos-blockchain-circuits-v<ver>-windows-x86_64-gnu.tar.gz`; without one it
  is simply absent, so this commit changes no existing output. `packages
  .x86_64-linux.default` is still `/nix/store/wzi7bxa2...-0.5.7`, byte-identical
  to upstream.

`actionlint` reports the same 11 pre-existing shellcheck infos before and after.

The job has not run: it needs a release build to exercise it, and the
`prover.exe` / `verifier.exe` the MSYS2 bundle ships are deliberately omitted
here, since this variant exists for linking rather than for running the tools.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dlipicar dlipicar changed the title fix(nix): expose the Windows archives this repo already publishes feat(nix,ci): make the Windows archives reachable, and publish a linkable MinGW-cross variant Sep 18, 2026
@dlipicar

Copy link
Copy Markdown
Author

Measured downstream, and it changes what this variant needs to guarantee.

Linking the cross variant's archives into wallet-ffi under
execution-zone#900
gets all the way to the final link and then stops on one symbol:

liblogos_blockchain_circuits_pol_sys...rlib(main.o):
  undefined reference to `fstat64i32'

I cross-built those archives with MinGW GCC 15.3; that consumer's nixpkgs
pins MinGW GCC 14.3, and fstat64i32 moved between those mingw-w64 CRT
versions. The same thing bit the bundled libmman.a one layer earlier, for the
same reason.

So the cross variant is coupled to the MinGW version that produced it, and
this PR as it stands does not say which that is — the job just takes whatever
g++-mingw-w64-x86-64 Ubuntu ships that week, which will drift.

Two ways to make that safe, and I'd rather you pick than guess:

  1. Pin and publish the toolchain version. Pin the mingw-w64 package in the
    job and record the GCC version in the bundle (a line in VERSION, or the
    asset name), so a consumer can match or at least fail loudly.
  2. Drop libmman.a from the bundle and let consumers supply mmap. It is a
    ~200-line shim, and shipping it prebuilt is what creates the coupling for
    that piece. This does not fix the circuit archives themselves, which carry
    the same dependency through main.o.

Happy to implement either here. Worth knowing the MSYS2 variant has this
property too — it is simply invisible today because nothing links it with a
different toolchain.

The cross variant was built with whatever mingw-w64 the runner image shipped,
and archives built with one mingw-w64 do not link with another. Measured:
cross-built with GCC 15.3 and linked by a consumer on GCC 14.3, `wallet-ffi`
stops on a single symbol --

    liblogos_blockchain_circuits_pol_sys...(main.o): undefined reference to `fstat64i32'

-- because the circuits' generated main.cpp calls fstat, and the two versions'
headers route it to different CRT names: GCC 14.3 to `_fstat64i32`, GCC 15.3 to
`fstat64i32`. Each CRT provides only its own. Nothing is wrong with the code;
the producer and consumer simply have to agree on a toolchain.

Consumers in this stack are nix builds that follow logos-nix, so that is what
the variant is now built with:

* A `logos-nix` input, used only for this. `nixpkgs` is untouched, so the native
  packages are byte-identical to before (default is still
  /nix/store/wzi7bxa2...-logos-blockchain-circuits-0.5.7). The lock pins it to
  2ab5416a, the rev logos-execution-zone locks today; relock them together.
* `devShells.x86_64-linux.windows-cross` puts that pinned MinGW on PATH with a
  static GMP from the same package set.
* `build-windows-cross.sh` and `verify-windows-cross.sh` hold the build and the
  gate, so CI and a developer run the same command:

      nix develop .#windows-cross -c sh .github/resources/witness-generator/build-windows-cross.sh <out> <circuit>...

  libmman is now compiled there too, with the archives' own toolchain, instead
  of being shipped as a prebuilt shim that can drift from them -- which it did
  (the same fstat64i32, one layer earlier).
* The job sets up Nix with logos-co/setup-nix-cache-action@v1, the org's action,
  so the toolchain comes from the Logos cache where present.
* The bundle carries a TOOLCHAIN file,
  `x86_64-w64-mingw32-gcc 14.3.0; logos-nix <rev>`, so a consumer can see what
  the archives need before it meets an undefined symbol at link.

Verified end to end: the committed scripts, run exactly as the job runs them,
build and pass the gate for all four circuits; and with the circuit and
rapidsnark archives built on this pinned toolchain, logos-execution-zone's
`wallet-windows-x86_64` builds through nix to wallet_ffi.dll (pei-x86-64, 58
exported wallet_ffi_* symbols).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dlipicar

Copy link
Copy Markdown
Author

Went with option 1 — pinned in b3e9516. The variant now builds with logos-nix's MinGW (GCC 14.3, locked to the same logos-nix rev as logos-execution-zone), and libmman is compiled alongside the archives instead of shipped prebuilt. With that, execution-zone#900's wallet-windows-x86_64 builds through nix to wallet_ffi.dll — the fstat64i32 failure is gone.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant