Skip to content

Conversation

@castholm
Copy link
Contributor

@castholm castholm commented Nov 7, 2025

Closes #25856
Closes #19072

  • Single-threaded Wasm builds should not have a dependency on pthreads.
  • std.heap.page_allocator should use PageAllocator, not WasmAllocator, when linking libc. It's the libc's responsibility to manage the heap and having Zig try to grow the main Wasm memory can cause all sorts of problems. Changing this fixes a long-standing annoyance with DebugAllocator being useless on WASI+libc/Emscripten unless the user overrides the (default) backing allocator. Now, DebugAllocator Just Works.
Test/repro:

Install/activate the latest Emscripten, run zig build --sysroot "$(em-config CACHE)/sysroot" && python -m http.server -d zig-out/www and open the page in your web browser.

// build.zig
const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.resolveTargetQuery(.{ .cpu_arch = .wasm32, .os_tag = .emscripten });
    const optimize: std.builtin.OptimizeMode = .Debug;

    const emscripten_system_include_path: std.Build.LazyPath = if (b.sysroot) |sysroot|
        .{ .cwd_relative = b.pathJoin(&.{ sysroot, "include" }) }
    else {
        std.process.fatal("'--sysroot' is required", .{});
    };

    const repro_mod = b.createModule(.{
        .root_source_file = b.path("build.zig"),
        .target = target,
        .optimize = optimize,
        .link_libc = true,
    });

    repro_mod.addSystemIncludePath(emscripten_system_include_path);

    const repro_lib = b.addLibrary(.{
        .linkage = .static,
        .name = "repro",
        .root_module = repro_mod,
    });

    const run_emcc = b.addSystemCommand(&.{"emcc"});
    run_emcc.addArtifactArg(repro_lib);

    run_emcc.addArgs(&.{
        "-O0",
        "-g",
        "-fsanitize=undefined",
    });

    // Patch the default HTML shell.
    run_emcc.addArg("--pre-js");
    run_emcc.addFileArg(b.addWriteFiles().add("pre.js", (
        // Display stderr output on the page
        \\Module['printErr'] ??= Module['print'];
        // Disable ANSI escape sequences
        \\Module['preRun'] = [].concat(Module['preRun'] ?? [], () => ENV.TERM ??= 'dumb');
    )));

    run_emcc.addArg("-o");
    const repro_html = run_emcc.addOutputFileArg("index.html");

    b.getInstallStep().dependOn(&b.addInstallDirectory(.{
        .source_dir = repro_html.dirname(),
        .install_dir = .{ .custom = "www" },
        .install_subdir = "",
    }).step);
}

pub const std_options: std.Options = .{ .log_level = .debug };

pub fn main() !void {
    var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
    defer std.debug.assert(debug_allocator.deinit() == .ok);

    const gpa = debug_allocator.allocator();

    const byte = try gpa.create(u8);
    defer gpa.destroy(byte);

    std.debug.print("Hello, World!\n", .{});
}

Without the changeset, emcc fails with error: undefined symbol: pthread_kill.
With the first commit, linking succeeds but the app OOMs.
With the last commit, the app works.

@castholm
Copy link
Contributor Author

castholm commented Nov 7, 2025

Because there was a similar PR in the past that was rejected with this comment #19057 (comment) I'll address it in advance:

My assumption is that because Zig uses WasmPageAllocator for WASM builds, it ends up stomping over Emscripten allocated memory (ie. SDL2/Wasm3's allocations) and vice-versa.

I don't see how this could be the case, since @wasmMemoryGrow increases the size of memory. This means two independent pieces of code could both call @wasmMemoryGrow to allocate pages, and they would get different pages.

I suggest for you to temporarily replace page_allocator with @compileError("don't do it") and then find and fix the code that is using page allocator rather than a more appropriate allocator.

However, neither that change, nor the one you opened in this PR are appropriate for the zig standard library.

The problem isn't that WasmAllocator thrashes the libc-managed heap. The problem is twofold:

  • The main Wasm memory might be configured as non-growable, meaning WasmAllocator can't allocate anything at all. Non-growable memory is the default on Emscripten.
  • Even with growable memory, the JavaScript side of Emscripten defines a bunch of TypedArray views into Wasm memory and the browser Wasm API has this really, really dumb behavior where growing memory will invalidate and detach those views, requiring user code to reattach them. This means that it's a bad idea to call @wasmMemoryGrow() on Emscripten because without notifying the JS side to reattach the views immediately after growing, your app will crash the next time you call out into any JS function that uses those views. I suspect that many in-browser WASI runner implementations will have the same problems.

@castholm castholm force-pushed the emscripten-fixes branch 2 times, most recently from 77bf60d to 249176b Compare November 8, 2025 05:20
@castholm
Copy link
Contributor Author

castholm commented Nov 8, 2025

For posterity, I dropped this commit because it turns out several seemingly unrelated parts of std still want to know the current thread id or CPU count, which std.Thread.UnsupportedImpl does not want to supply:

diff --git a/lib/std/Thread.zig b/lib/std/Thread.zig
index 59c6d78166..ae2079fa45 100644
--- a/lib/std/Thread.zig
+++ b/lib/std/Thread.zig
@@ -20,7 +20,7 @@ pub const RwLock = @import("Thread/RwLock.zig");
 pub const Pool = @import("Thread/Pool.zig");
 pub const WaitGroup = @import("Thread/WaitGroup.zig");
 
-pub const use_pthreads = native_os != .windows and native_os != .wasi and builtin.link_libc;
+pub const use_pthreads = native_os != .windows and native_os != .wasi and builtin.link_libc and !builtin.single_threaded;
 
 /// A thread-safe logical boolean value which can be `set` and `unset`.
 ///

I still believe a fix like this is in the right spirit; single-threaded builds should not have any references to pthreads. But fixing it by adding something like std.Thread.SingleThreadedImpl would distract from the specific goals of this PR. Plus, I suspect that these parts will get rewritten and overhauled as std.Io develops further anyway.

Fixes a regression preventing single-threaded Emscripten builds from
linking successfully without passing `-pthread` to emcc.
When linking libc, it should be the libc that manages the heap. The main
Wasm memory might have been configured as non-growable, which makes
`WasmAllocator` a poor default and causes the common `DebugAllocator`
use case fail with OOM errors unless the user uses `std_options` to
override the default page allocator. Additionally, on Emscripten,
growing Wasm memory without notifying the JS glue code will cause array
buffers to get detached and lead to spurious crashes.
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.

wasm32-emscripten started to depend on clib thread functions emscripten: OutOfMemory bug when using default page_allocator

1 participant