Linking against GHC-WASM foreign library?

Rust has recently gotten a compiler flag which enables Rust+C WASM compilation, and here is a repository of examples of how it would be done. I know that both Haskell and Rust have C-FFI and both can target WASM (although for Haskell its only WASI now??) so my hope was that there could be a way to replicate what is done in the example repo, except instead of C binaries, Haskell is linked instead.
So the end goal would be a Rust crate which wraps some Haskell library (I am particularly interested in HaTeX since I haven’t found a popular crate like this in Rust) which has a build-script that invokes GHC WASM, and links (statically) whatever the output of that is. Then other Rust crates which target WASM can depend on the wrapper-crate to access said Haskell-library functionality, and when the final build of a whole host of these WASM-targeting crates is done, the Haskell-compiled WASM is included in the final output.

What I have tried so far

Executable approach

I followed the Haskell WASM GHC Repo to set up (using Nix-shell) a basic Haskell-WASM with the following structure:

cbits\
  wizerinit.c    <-- Near-verbatim copy of the Wizer/Preinitialization code from the
                     GHC WASM Repo's README documentation
src\
  Lib.hs         <-- The ForeignFunctionInterface module, exports `hs_dummy_init :: IO ()`
                     and `hs_fib :: CInt -> CInt` foreign functions to test everything
  OtherModule.hs <-- An empty dummy module to make sure multi-module projects also work
latex-preprocessor.cabal

and then finally the contents of my latex-preprocessor.cabal are

cabal-version:   3.12
name:            latex-preprocessor
version:         0

executable latex-preprocessor
  main-is:            Lib.hs
  other-modules:      OtherModule
  c-sources:          cbits/wizerinit.c
  hs-source-dirs:     src
  default-language:   GHC2021
  build-depends:       
    , base
  ghc-options:
    -Wall
    -no-hs-main
    -optl-mexec-model=reactor
    "-optl-Wl,--export=hs_dummy_init,--export=hs_fib"

and then running the following sequence of commands:

wasm32-wasi-cabal build exe:latex-preprocessor
LATEX_PREPROCESSOR_WASM="$(wasm32-wasi-cabal list-bin exe:latex-preprocessor)"
wizer --allow-wasi --wasm-bulk-memory true \
  "$LATEX_PREPROCESSOR_WASM" -o "dist-newstyle/haskell.wasm"

it all seems to work fine, no errors or anything, and I even get the final output in dist-newstyle/haskell.wasm.

With that, I tried to get the Rust side of things working. I chose to basically follow the first example from that Rust+C repository; where I compile both the Rust and Haskell separately, and then link them with wasm-ld. I set the toolchain to nightly and passed the wasm_c_abi flag in config files, so my version of the script only needed to call cargo build --release:

# Build Rust
cargo build --release

# Create a static library from the Rust build
cp ./../target/wasm32-unknown-unknown/release/liblatex_preprocessor.rlib ./build/rust.wasm

# Build Haskell
cd ./haskell
wasm32-wasi-cabal build exe:latex-preprocessor
LATEX_PREPROCESSOR_WASM="$(wasm32-wasi-cabal list-bin exe:latex-preprocessor)"
wizer --allow-wasi --wasm-bulk-memory true \
  "$LATEX_PREPROCESSOR_WASM" -o "dist-newstyle/haskell.wasm"
cd ./..

# Create a static library from the Haskell build
cp ./haskell/dist-newstyle/haskell.wasm ./build/haskell.wasm

# Link rust.wasm and haskell.wasm to a single WebAssembly binary
wasm-ld \
    --error-limit=0 \
    --no-entry \
    --export-all \
    -o ./build/latex_preprocessor.wasm \
    ./build/rust.wasm \
    ./build/haskell.wasm

# you can use wasm2wat to disassemble it and see exported symbols
wasm2wat ./build/latex_preprocessor.wasm > ./build/latex_preprocessor.wat

and just to make sure the output wasn’t completely missing in the final WASM module, I added the following (only) function to the lib.rs file:

#[no_mangle]  
pub extern "C" fn add(left: usize, right: usize) -> usize {  
  left + right  
}

Running the whole thing yielded an error, where wasm-ld was complaining that wasm-ld: error: ./build/haskell.wasm: not a relocatable wasm file. Not sure what this means, but I’m assuming it has something to do with the fact that I just built an executable rather than library?

Library approach

All the examples I’ve come across online for GHC+Wasm are building executables, rather than libraries. So I have no idea if building a library is the appropriate thing to even do, but I tried. I changed the executable stanza in my cabal file to a library stanza like this:

library
  exposed-modules:    Lib
  other-modules:      OtherModule
  c-sources:          cbits/wizerinit.c
  hs-source-dirs:     src
  default-language:   GHC2021
  build-depends:       
    , base
  ghc-options:
    -Wall
    -no-hs-main
    -optl-mexec-model=reactor
    "-optl-Wl,--export=hs_dummy_init,--export=hs_fib"

and running wasm32-wasi-cabal build lib:latex-preprocessor now produced a bunch of object files corresponding to each of the source files, and finally two library files .a and .so. I assume the .a file would be the one that has the web-assembly? So I copied it directly to where the old output used to be:

wasm32-wasi-cabal build lib:latex-preprocessor
LATEX_PREPROCESSOR_WASM=./dist-newstyle/build/wasm32-wasi/ghc-9.10.1.20241209/latex-preprocessor-0/build/libHSlatex-preprocessor-0-inplace.a
cp "$LATEX_PREPROCESSOR_WASM" ./dist-newstyle/haskell.wasm

wizer --allow-wasi --wasm-bulk-memory true \
  "./dist-newstyle/haskell.wasm" -o "dist-newstyle/haskell.wasm"

Running this, I got the library to build, but the wizer step failed, with the error message being about a magic-number miss-match. I’m guessing this is because it only works with executable WASM builds, and this is a library?? Anyways, I omitted that last wizer step so that my new top-level build script looks like this now:

# Build Rust
cargo build --release

# Create a static library from the Rust build
cp ./../target/wasm32-unknown-unknown/release/liblatex_preprocessor.rlib ./build/rust.wasm

# Build Haskell
cd ./haskell
wasm32-wasi-cabal build lib:latex-preprocessor
LATEX_PREPROCESSOR_WASM=./dist-newstyle/build/wasm32-wasi/ghc-9.10.1.20241209/latex-preprocessor-0/build/libHSlatex-preprocessor-0-inplace.a
cp "$LATEX_PREPROCESSOR_WASM" ./dist-newstyle/haskell.wasm
cd ./..

# Create a static library from the Haskell build
cp ./haskell/dist-newstyle/haskell.wasm ./build/haskell.wasm

# Link rust.wasm and haskell.wasm to a single WebAssembly binary
wasm-ld \
    --error-limit=0 \
    --no-entry \
    --export-all \
    -o ./build/latex_preprocessor.wasm \
    ./build/rust.wasm \
    ./build/haskell.wasm

# you can use wasm2wat to disassemble it and see exported symbols
wasm2wat ./build/latex_preprocessor.wasm > ./build/latex_preprocessor.wat

Running this now works without errors, however the output .wat file is almost entirely empty (and only 500 bytes) with even the add function (the one I added to lib.rs for troubleshooting) is missing from the exports. I read online that you need the --whole-archive flag for wasm-ld to include all the contents that were in the archives. I only first tried it on rust.wasm to make sure it works:

# Link rust.wasm and haskell.wasm to a single WebAssembly binary
wasm-ld --error-limit=0 --no-entry --export-all -o ./build/latex_preprocessor.wasm \
    --whole-archive ./build/rust.wasm --no-whole-archive \
    ./build/haskell.wasm

and sure enough the new .wat file now had the add function in the exports:

(module $latex_preprocessor.wasm
  ...
  (func $add (type 1) (param i32 i32) (result i32)
    local.get 1
    local.get 0
    i32.add)
  ...
  (export "__wasm_call_ctors" (func $__wasm_call_ctors))
  (export "add" (func $add))
  ...

however when I extended it to haskell.wasm also, I started having a whole bunch of errors. Running this:

# Link rust.wasm and haskell.wasm to a single WebAssembly binary
wasm-ld --error-limit=0 --no-entry --export-all -o ./build/latex_preprocessor.wasm \
    --whole-archive ./build/rust.wasm \
    ./build/haskell.wasm --no-whole-archive

yielded the following errors:

wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: __Sp
...
wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: ghczmprim_GHCziTuple_Z0T_closure
...
wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: __stg_gc_fun
wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: __R2
...
wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: ghczmprim_GHCziTypes_True_closure
wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: __R4
...
wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: rts_apply
wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: rts_inCall
wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: rts_checkSchedStatus
wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: rts_unlock
wasm-ld: error: ./build/haskell.wasm(Lib.o): undefined symbol: rts_lock
...
wasm-ld: error: ./build/haskell.wasm(wizerinit.o): undefined symbol: hs_perform_gc
wasm-ld: error: ./build/haskell.wasm(wizerinit.o): undefined symbol: hs_perform_gc
wasm-ld: error: ./build/haskell.wasm(wizerinit.o): undefined symbol: rts_clearMemory
wasm-ld: error: ./build/haskell.wasm(wizerinit.o): undefined symbol: malloc_inspect_all

from this, I can see that a whole bunch of symbols are missing (from Haskell’s RTS and also their patched version of libc which exposes malloc_inspect_all.) I’m assuming this is because GHC built a library/archive which dynamically links to all these in instead of statically linking them??

Speculations + Questions

I started scouring for resources related to GHC+static linking, found out that there is a foreign-library stanza for cabal, that there are all sorts of GHC flags like shared, static, -fPIC and so on, aswell as linker flags like -optl-Wl,--relocatable, but I’m not exactly sure how to use any of them, or how they interact with specifically the WASM backend.

Also, assuming that the wizer stuff only really works on WASM executables, then I guess I should expose the contents of cbits/wizerinit.c in my Rust wrapper crate, which will then continue to propagate up the chain of crates that use it (they can optionally tack-on extra initialization functionality if they need it and continue to re-expose) until the final crate which compiles to WASM executable can also expose it, and only THEN I can run wizer on the final output?

Another thing which I just ignored hoping it wasn’t a big deal: the GHC compiler targets wasm32-wasi while the Rust crates I’m hoping to build target wasm32-unknown-unknown (I want to use wasm-bindgen and this requires I target the -unknown-unknown triple.) My hope is that I CAN infact link/bundle these together, and all it means is that I have to provide browser-side WASI polyfill when running the final WASM module, without any issues. But this is purely wishful thinking, I have no idea what its actually going to mean.

So the final question I have is, does anyone know if this is even possible? And if so, are there any resources, examples, articles, documentation, and so on, regarding how to do this stuff? In regards to the unique blend of GHC+WASM+C-FFI in particular I mean.

EDIT: forgot to mention that in my mad quest for figuring this out, I came across multiple resources stating that WASM doesn’t have multiple-memory yet and something about importing linear_memory from the env module?? I have no concrete idea what any of that means, but I’m assuming that this means you have to do very special handling when making WASM modules so the individual component’s memory layouts don’t conflict, or something? Honestly this bit of research only added to my overall confusion.

6 Likes

You most likely needs the -staticlib ghc option to link all haskell code including the rts to a static archive, to be linked by wasm-ld when later linking with cargo. Unfortunately there’s #22586: -staticlib creates malformed archives in wasm backend · Issues · Glasgow Haskell Compiler / GHC · GitLab, I haven’t checked if that’s still the case as of today, will give it another shot later.

3 Likes

yeah, similar to your bug report, once I added -staticlib flag, the compilation failed with:

[3 of 3] Linking liba.a
LLVM ERROR: malformed uleb128, extends past end
PLEASE submit a bug report to https://github.com/llvm/llvm-project/issues/ and include the crash backtrace.
Stack dump:
0.	Program arguments: /nix/store/nga4q9yyck5dcga257xad38g7523hb8v-wasi-sdk/bin/llvm-ranlib liba.a
wasm32-wasi-ghc-9.10.1.20241209: `llvm-ranlib' failed in phase `Ranlib'. (Exit code: -6)
Error: [Cabal-7125]
Failed to build latex-preprocessor-0.

so I guess this bug persists?

could this have something to do with GHC-WASM not supporting multi-threaded/parallel stuff yet? And perhaps something being linked is multi-threaded? Or am I way off?

It’s not related to multi-threaded rts. It’s just a bug in GHC. Thanks for re-confirming it and I’ll take some time to look into it again in the coming weeks.