Wasm "index out of bounds" only in debug mode

Hi all, I was trying to compile Zig to WASM with some sample code (see below), when I got hit with this error:

Uncaught RuntimeError: index out of bounds
    add http://127.0.0.1:57391/zig-out/bin/main.wasm:126
    <anonymous> http://127.0.0.1:57391/src/index.html:14
    InterpretGeneratorResume self-hosted:1425
    AsyncFunctionNext self-hosted:800

Oddly, this only happens when compiling in debug mode. Compiling in any of the release modes produces the expected output:

5
a + b = 5
Source code

build.zig:

const std = @import("std");

pub fn build(b: *std.Build) void {
    const optimize = b.standardOptimizeOption(.{});

    const wasm = b.addLibrary(.{
        .name = "wasm",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/main.zig"),
            .target = b.resolveTargetQuery(.{
                .cpu_arch = .wasm32,
                .ofmt = .wasm,
                .os_tag = .freestanding,
            }),
            .optimize = optimize,
        }),
        .use_lld = false,
    });
    wasm.rdynamic = true;

    const install_wasm = b.addInstallArtifact(wasm, .{
        .dest_dir = .{ .override = .bin },
        .dest_sub_path = "main.wasm",
    });
    b.getInstallStep().dependOn(&install_wasm.step);
}

src/main.zig

extern fn print(a: i32) void;

export fn add(a: i32, b: i32) i32 {
    print(a + b);
    return a + b;
}

src/index.html:

<!doctype html>
<script type="module">
  const importObject = {
    env: {
      print: function (x) {
        console.log(x);
      },
    },
  };
  const { instance } = await WebAssembly.instantiateStreaming(
    fetch("../zig-out/bin/main.wasm"), // Compiled by zig
    importObject,
  );
  console.log(`a + b = ${instance.exports.add(2, 3)}`); // Error occurs on this line
</script>

My first thought was that this was a regression in the new self-hosted x86 backend, but nope, setting .use_llvm = true didn’t change anything. I tried setting use_lld to true, but still nothing. I tried out more Zig versions and surprisingly, 0.13.0 worked.

zig version works
0.13.0 :white_check_mark:
0.14.0 :cross_mark:
0.14.1 :cross_mark:
master :cross_mark:

Digging a bit deeper, I found out that the error goes away if I remove all arguments from the add function (perhaps the function arguments are passed as an array?).

I didn’t manage to find an issue in the github repo documenting this. Is this a regression or am I doing something wrong (what could even go wrong with such a small piece of code)?

1 Like

This feels like a miscompilation to me. Can you use wasm-tools or similar to print out the wat version of the wasm and see what it gives you? You could even compare between release and debug modes. This will likely produce a big output so you may have to filter it down to your add function.
You could even compare the output from 0.13 to one of the others and see what changed.

-    const wasm = b.addLibrary(.{
+    const wasm = b.addExecutable(.{
         .name = "wasm",
         .root_module = b.createModule(.{
             .root_source_file = b.path("src/main.zig"),
             .target = b.resolveTargetQuery(.{
                 .cpu_arch = .wasm32,
                 .ofmt = .wasm,
                 .os_tag = .freestanding,
             }),
             .optimize = optimize,
         }),
         .use_lld = false,
     });
+    wasm.entry = .disabled;
     wasm.rdynamic = true;

The problem is that you’re building a static library, which might not always result in a legal and working Wasm binary. Since Zig 0.12.0, when compiling for Wasm you need to build your code as an executable without an entry point (-fno-entry (CLI) or exe.entry = .disabled (build system)).

6 Likes

Thanks, that fixed it for Zig 0.14.1! Oddly enough, it doesn’t work on master (0.15.0-dev.875+75d6d4c3f), not that I mind too much though.

Edit: never mind turns out the code didn’t actually compile because of .use_lld = false. Re-compiled it and now it works on both 0.14.1 and master.

In case anyone else has the same issue, this is my final build.zig:

const std = @import("std");

pub fn build(b: *std.Build) void {
    const optimize = b.standardOptimizeOption(.{});

    const wasm = b.addExecutable(.{
        .name = "wasm",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/main.zig"),
            .target = b.resolveTargetQuery(.{
                .cpu_arch = .wasm32,
                .os_tag = .freestanding,
            }),
            .optimize = optimize,
        }),
    });
    wasm.entry = .disabled;
    wasm.rdynamic = true;

    const install_wasm = b.addInstallArtifact(wasm, .{
        .dest_dir = .{ .override = .bin },
        .dest_sub_path = "main.wasm",
    });
    b.getInstallStep().dependOn(&install_wasm.step);
}
2 Likes

It would actually be nice to know why that is necessary.

A static library is basically an incomplete object file that expects to be compiled in with other object files. Usually this means that many functions have pointers and offsets that are not yet correct, and which a linker is meant to correct when combining with other objects.

A look at the wasm text format, and I assume that in this case, the problem lay in the __stack_pointer global. The static library assumes that the stack (the linear memory stack used by most compiled languages, not WebAssembly’s internal stack) was setup by the entry point, and tries to write data into it. But there is no memory allocated to the module, and hence the index out of bounds error. (Edit Correction:) It’s not that the memory for the stack isn’t set up, but that when compiled to a static library, the __stack_pointer global is set to a negative number for its placeholder. So when trying to write to the stack, it’s trying to write to a negative index, which is out of bounds.

The error only happens in Debug mode, because the other modes optimize out the interaction with the non-existent stack memory. Meanwhile, making the artifact into an executable, and without a default entry point, means that the compiler knows that it needs to sets up the stack pointer on its own, so even though Debug mode is still using the stack, it now works.

1 Like