diff -ruN c/pthread.zig c/pthread.zig --- c/pthread.zig 2026-09-08 03:57:16.000000000 +0200 +++ c/pthread.zig 2026-09-14 13:56:38.901941121 +0200 @@ -7,11 +7,11 @@ comptime { if (builtin.target.isMuslLibC() or builtin.target.isWasiLibC() or builtin.target.isMinGW()) { - symbol(&pthread_spin_init, "pthread_spin_init"); - symbol(&pthread_spin_destroy, "pthread_spin_destroy"); - symbol(&pthread_spin_trylock, "pthread_spin_trylock"); - symbol(&pthread_spin_lock, "pthread_spin_lock"); - symbol(&pthread_spin_unlock, "pthread_spin_unlock"); + // symbol(&pthread_spin_init, "pthread_spin_init"); + // symbol(&pthread_spin_destroy, "pthread_spin_destroy"); + // symbol(&pthread_spin_trylock, "pthread_spin_trylock"); + // symbol(&pthread_spin_lock, "pthread_spin_lock"); + // symbol(&pthread_spin_unlock, "pthread_spin_unlock"); } } diff -ruN std/heap/BrkAllocator.zig std/heap/BrkAllocator.zig --- std/heap/BrkAllocator.zig 2026-09-08 03:57:21.000000000 +0200 +++ std/heap/BrkAllocator.zig 2026-09-14 13:18:11.349880552 +0200 @@ -11,10 +11,6 @@ const assert = std.debug.assert; const math = std.math; -comptime { - if (!builtin.single_threaded) @compileError("unsupported"); -} - next_addrs: [size_class_count]usize = @splat(0), /// For each size class, points to the freed pointer. frees: [size_class_count]usize = @splat(0), diff -ruN std/heap.zig std/heap.zig --- std/heap.zig 2026-09-08 03:57:21.000000000 +0200 +++ std/heap.zig 2026-09-14 12:59:19.981000286 +0200 @@ -15,9 +15,79 @@ pub const FixedBufferAllocator = @import("heap/FixedBufferAllocator.zig"); pub const BufferFirstAllocator = @import("heap/BufferFirstAllocator.zig"); pub const PageAllocator = @import("heap/PageAllocator.zig"); -pub const WasmAllocator = if (builtin.single_threaded) BrkAllocator else @compileError("unimplemented"); +pub const WasmAllocator = if (builtin.single_threaded) BrkAllocator else ThreadSafe(BrkAllocator); pub const BrkAllocator = @import("heap/BrkAllocator.zig"); +/// Wraps a non-thread-safe allocator and makes it thread-safe. +fn ThreadSafe(comptime T: type) type { + return struct { + nts_allocator: T = .{}, + state: u32 = 0, + + pub fn allocator(self: *@This()) Allocator { + return .{ + .ptr = self, + .vtable = &vtable, + }; + } + + pub const vtable: Allocator.VTable = .{ + .alloc = alloc, + .resize = resize, + .remap = remap, + .free = free, + }; + + fn alloc(ctx: *anyopaque, n: usize, alignment: std.mem.Alignment, ra: usize) ?[*]u8 { + const self: *@This() = @ptrCast(@alignCast(ctx)); + self.lock(); + defer self.unlock(); + return ntsAlloc(&self.nts_allocator, n, alignment, ra); + } + + fn resize(ctx: *anyopaque, buf: []u8, alignment: std.mem.Alignment, new_len: usize, ret_addr: usize) bool { + const self: *@This() = @ptrCast(@alignCast(ctx)); + self.lock(); + defer self.unlock(); + return ntsResize(&self.nts_allocator, buf, alignment, new_len, ret_addr); + } + + fn remap(context: *anyopaque, memory: []u8, alignment: std.mem.Alignment, new_len: usize, return_address: usize) ?[*]u8 { + const self: *@This() = @ptrCast(@alignCast(context)); + self.lock(); + defer self.unlock(); + return ntsRemap(&self.nts_allocator, memory, alignment, new_len, return_address); + } + + fn free(ctx: *anyopaque, buf: []u8, alignment: std.mem.Alignment, ret_addr: usize) void { + const self: *@This() = @ptrCast(@alignCast(ctx)); + self.lock(); + defer self.unlock(); + return ntsFree(&self.nts_allocator, buf, alignment, ret_addr); + } + + fn lock(self: *@This()) void { + while (@cmpxchgWeak(u32, &self.state, 0, 1, .acquire, .monotonic) != null) { + var tio: std.Io.Threaded = undefined; + futexWaitUncancelable(&tio, &self.state, 1); + } + } + + fn unlock(self: *@This()) void { + @atomicStore(u32, &self.state, 0, .release); + var tio: std.Io.Threaded = undefined; + futexWake(&tio, &self.state, 1); + } + + const ntsAlloc = T.vtable.alloc; + const ntsResize = T.vtable.resize; + const ntsRemap = T.vtable.remap; + const ntsFree = T.vtable.free; + const futexWaitUncancelable = std.Io.Threaded.io(undefined).vtable.futexWaitUncancelable; + const futexWake = std.Io.Threaded.io(undefined).vtable.futexWake; + }; +} + /// Deprecated; use `SafeAllocator.Options`. pub const DebugAllocatorConfig = @import("heap/debug_allocator.zig").Config; /// Deprecated; use `SafeAllocator`. diff -ruN std/Io/Threaded.zig std/Io/Threaded.zig --- std/Io/Threaded.zig 2026-09-08 03:57:21.000000000 +0200 +++ std/Io/Threaded.zig 2026-09-14 13:03:41.515873104 +0200 @@ -939,6 +939,8 @@ return Thread.futexWaitInner(ptr, expect, false, timeout_ns); } + var main_thread_spin_ptr: ?*const u32 = null; + fn futexWaitInner(ptr: *const u32, expect: u32, uncancelable: bool, timeout_ns: ?u64) Io.Cancelable!void { @branchHint(.cold); @@ -958,6 +960,37 @@ comptime assert(builtin.cpu.has(.wasm, .atomics)); // TODO implement cancelation for WASM futex waits by signaling the futex if (!uncancelable) try Thread.checkCancel(); + if (std.options.wasm_main_thread_no_wait) { + // see if the current thread is the main thread + if (currentId() == 0) { + // enter a spin instead of using the wait instruction + @atomicStore(?*const u32, &main_thread_spin_ptr, ptr, .monotonic); + errdefer @atomicStore(?*const u32, &main_thread_spin_ptr, null, .monotonic); + if (timeout_ns) |ns| { + if (builtin.target.os.tag == .wasi) { + var ns_start: std.os.wasi.timestamp_t = 0; + if (std.os.wasi.clock_time_get(.MONOTONIC, 1, &ns_start) != .SUCCESS) + @panic("unsupported"); + // keep spinning until futexWake sets main_thread_spin_ptr to null + while (@atomicLoad(?*const u32, &main_thread_spin_ptr, .acquire) != null) { + if (!uncancelable) try Thread.checkCancel(); + var ns_now: std.os.wasi.timestamp_t = 0; + if (std.os.wasi.clock_time_get(.MONOTONIC, 1, &ns_now) != .SUCCESS) + @panic("unsupported"); + if (ns_now - ns_start >= ns) { + @atomicStore(?*const u32, &main_thread_spin_ptr, null, .monotonic); + break; + } + } + } else @panic("unsupported"); + } else { + while (@atomicLoad(?*const u32, &main_thread_spin_ptr, .acquire) != null) { + if (!uncancelable) try Thread.checkCancel(); + } + } + return; + } + } const to: i64 = if (timeout_ns) |ns| std.math.cast(i64, ns) orelse std.math.maxInt(i64) else -1; const signed_expect: i32 = @bitCast(expect); const result = asm volatile ( @@ -1114,6 +1147,14 @@ return parking_futex.wake(ptr, max_waiters); } else if (builtin.cpu.arch.isWasm()) { comptime assert(builtin.cpu.has(.wasm, .atomics)); + var remaining_waiters = max_waiters; + if (std.options.wasm_main_thread_no_wait) { + if (@atomicLoad(?*const u32, &main_thread_spin_ptr, .monotonic) == ptr) { + @atomicStore(?*const u32, &main_thread_spin_ptr, null, .release); + remaining_waiters -= 1; + } + if (remaining_waiters == 0) return; + } const woken_count = asm volatile ( \\local.get %[ptr] \\local.get %[waiters] @@ -1121,7 +1162,7 @@ \\local.set %[ret] : [ret] "=r" (-> u32), : [ptr] "r" (ptr), - [waiters] "r" (max_waiters), + [waiters] "r" (remaining_waiters), ); _ = woken_count; // can be 0 when linker flag 'shared-memory' is not enabled } else switch (native_os) { diff -ruN std/std.zig std/std.zig --- std/std.zig 2026-09-08 03:57:21.000000000 +0200 +++ std/std.zig 2026-09-14 13:22:33.949163825 +0200 @@ -197,6 +197,11 @@ else => false, }, + /// The wait instruction is generally not allowed when WebAssembly is running in the main + /// (or UI) thread of a WebBrpwser. Setting this to true will cause `futexWait()` to spin in + /// the main thread while awaiting a call to `futexWake()` in a different thread. + wasm_main_thread_no_wait: bool = false, + /// TODO This is a separate decl instead of a field as a workaround around /// compilation errors due to zig not being lazy enough. pub const logTerminalMode: fn () Io.Terminal.Mode = log.defaultTerminalMode; diff -ruN std/Thread.zig std/Thread.zig --- std/Thread.zig 2026-09-08 03:57:21.000000000 +0200 +++ std/Thread.zig 2026-09-14 13:11:47.824695851 +0200 @@ -827,17 +827,25 @@ allocator: std.mem.Allocator, /// The current state of the thread. state: State = State.init(.running), + /// Stack memory for use by clean-up routine during thread cancellation + cleanup_stack_memory: ?[]u8 = null, + + fn deinit(self: *@This()) void { + // use free in the vtable so the stack doesn't get set to undefined when optimize = Debug + const free = self.allocator.vtable.free; + const ptr = self.allocator.ptr; + free(ptr, self.memory, .@"1", 0); + if (self.cleanup_stack_memory) |sm| free(ptr, sm, .@"16", 0); + } }; /// A meta-data structure used to bootstrap a thread const Instance = struct { thread: WasiThread, - /// Contains the offset to the new __tls_base. - /// The offset starting from the memory's base. - tls_offset: usize, - /// Contains the offset to the stack for the newly spawned thread. - /// The offset is calculated starting from the memory's base. - stack_offset: usize, + /// Contains the address to the new __tls_base. + tls_address: usize, + /// Contains the address to the stack for the newly spawned thread. + stack_address: usize, /// Contains the raw pointer value to the wrapper which holds all arguments /// for the callback. raw_ptr: usize, @@ -845,11 +853,6 @@ /// function upon thread spawn. The above mentioned pointer will be passed /// to this function pointer as its argument. call_back: *const fn (usize) void, - /// When a thread is in `detached` state, we must free all of its memory - /// upon thread completion. However, as this is done while still within - /// the thread, we must first jump back to the main thread's stack or else - /// we end up freeing the stack that we're currently using. - original_stack_pointer: [*]u8, }; const State = std.atomic.Value(enum(u8) { running, completed, detached }); @@ -875,12 +878,7 @@ } fn join(self: Impl) void { - defer { - // Create a copy of the allocator so we do not free the reference to the - // original allocator while freeing the memory. - var allocator = self.thread.allocator; - allocator.free(self.thread.memory); - } + defer self.thread.deinit(); while (true) { const tid = self.thread.tid.load(.seq_cst); @@ -987,13 +985,13 @@ wrapper.* = .{ .args = args }; const instance: *Instance = @ptrCast(@alignCast(&allocated_memory[instance_offset])); + const start_address = @intFromPtr(allocated_memory.ptr); instance.* = .{ .thread = .{ .memory = allocated_memory, .allocator = config.allocator.? }, - .tls_offset = tls_offset, - .stack_offset = stack_offset, + .tls_address = start_address + tls_offset, + .stack_address = start_address + stack_offset, .raw_ptr = @intFromPtr(wrapper), .call_back = &Wrapper.entry, - .original_stack_pointer = __get_stack_pointer(), }; const tid = spawnWasiThread(instance); @@ -1014,46 +1012,95 @@ } /// Called by the host environment after thread creation. - fn wasi_thread_start(tid: i32, arg: *Instance) callconv(.c) void { - comptime assert(!builtin.single_threaded); - __set_stack_pointer(arg.thread.memory.ptr + arg.stack_offset); - __wasm_init_tls(arg.thread.memory.ptr + arg.tls_offset); - @atomicStore(u32, &WasiThreadImpl.tls_thread_id, @intCast(tid), .seq_cst); - - // Finished bootstrapping, call user's procedure. - arg.call_back(arg.raw_ptr); - - switch (arg.thread.state.swap(.completed, .seq_cst)) { - .running => { - // reset the Thread ID - asm volatile ( - \\ local.get %[ptr] - \\ i32.const 0 - \\ i32.atomic.store 0 - : - : [ptr] "r" (&arg.thread.tid.raw), - ); - - // Wake the main thread listening to this thread - asm volatile ( - \\ local.get %[ptr] - \\ i32.const 1 # waiters - \\ memory.atomic.notify 0 - \\ drop # no need to know the waiters - : - : [ptr] "r" (&arg.thread.tid.raw), - ); - }, - .completed => unreachable, - .detached => { - // restore the original stack pointer so we can free the memory - // without having to worry about freeing the stack - __set_stack_pointer(arg.original_stack_pointer); - // Ensure a copy so we don't free the allocator reference itself - var allocator = arg.thread.allocator; - allocator.free(arg.thread.memory); - }, - } + fn wasi_thread_start(_: i32, _: *Instance) callconv(.naked) void { + const clothed = struct { + fn run(tid: i32, arg: *Instance) callconv(.c) void { + if (arg.thread.cleanup_stack_memory == null) { + // Initialize thread-local variables. + __wasm_init_tls(@ptrFromInt(arg.tls_address)); + } else { + // Testore the TLS base so thread-local variables are available during clean-up. + __tls_set_base(@ptrFromInt(arg.tls_address)); + } + @atomicStore(u32, &WasiThreadImpl.tls_thread_id, @intCast(tid), .seq_cst); + + // Finished bootstrapping, call user's procedure. + arg.call_back(arg.raw_ptr); + + // Set thread state and free memory allocated for thread. + switch (arg.thread.state.swap(.completed, .seq_cst)) { + .running => { + // reset the Thread ID + asm volatile ( + \\ local.get %[ptr] + \\ i32.const 0 + \\ i32.atomic.store 0 + : + : [ptr] "r" (&arg.thread.tid.raw), + ); + + // Wake the main thread listening to this thread + asm volatile ( + \\ local.get %[ptr] + \\ i32.const 1 # waiters + \\ memory.atomic.notify 0 + \\ drop # no need to know the waiters + : + : [ptr] "r" (&arg.thread.tid.raw), + ); + }, + .completed => unreachable, + .detached => arg.thread.deinit(), + } + } + }; + // Set the stack pointer then jump to the "clothed" portion of the function. + asm volatile ( + \\ local.get 1 + \\ i32.load %[stack_address_offset] + \\ global.set __stack_pointer + \\ local.get 0 + \\ local.get 1 + \\ call %[cont] + \\ return + : + : [stack_address_offset] "X" (@offsetOf(Instance, "stack_address")), + [cont] "X" (&clothed.run), + ); + } + + /// Send a cancelation request to the WebAssembly host. Currently there's no standard function + /// defined for this purpose. To implement cancelation in non-standard manner, you need to + /// provide the import name (and library name) of the cancelation function. + /// + /// To shut down a thread immediately, the cancelation function should use Worker.terminate() + /// to kill the worker and spawn a replacement worker to finish the clean-up process. + /// + /// If deferred cancelation is requested, the host should wait until the thread makes a WASI + /// call, at which point it should throw an JavaScript error to halt WebAssembly execution. + /// The same worker can then be used to perform clean-up. + /// + /// We reuse `wasi_tread_start` to run the clean-up routine. Fresh stack memory is allocated + /// for it use, as the original stack would be in an unknown state. The caller of this function + /// needs to specify the amount. + pub fn cancel(self: *@This(), immediate: bool, cb: *const fn (usize) void, arg: usize, comptime options: struct { + stack_size: usize, + cancel_fn: struct { + name: []const u8, + library_name: ?[]const u8, + }, + }) !void { + const stack_mem = try self.thread.allocator.alignedAlloc(u8, .@"16", options.stack_size); + const instance: *Instance = @fieldParentPtr("thread", self.thread); + instance.call_back = cb; + instance.raw_ptr = arg; + instance.stack_address = @intFromPtr(stack_mem.ptr); + self.thread.cleanup_stack_memory = stack_mem; + const cancelThread = @extern(*const fn (i32, *Instance, bool) callconv(.c) void, .{ + .name = options.cancel_fn.name, + .library_name = options.cancel_fn.library_name, + }); + cancelThread(self.thread.tid.raw, instance, immediate); } /// Asks the host to create a new thread for us. @@ -1076,6 +1123,17 @@ ); } + /// Set the base of the TLS data segment for the current thread + inline fn __tls_set_base(ptr: [*]u8) void { + asm volatile ( + \\ .globaltype __tls_base, i32 + \\ local.get %[ptr] + \\ global.set __tls_base + : + : [ptr] "r" (ptr), + ); + } + /// Returns the size of the TLS segment inline fn __tls_size() u32 { return asm volatile ( @@ -1095,25 +1153,6 @@ : [ret] "=r" (-> u32), ); } - - /// Allows for setting the stack pointer in the WebAssembly module. - inline fn __set_stack_pointer(addr: [*]u8) void { - asm volatile ( - \\ local.get %[ptr] - \\ global.set __stack_pointer - : - : [ptr] "r" (addr), - ); - } - - /// Returns the current value of the stack pointer - inline fn __get_stack_pointer() [*]u8 { - return asm ( - \\ global.get __stack_pointer - \\ local.set %[stack_ptr] - : [stack_ptr] "=r" (-> [*]u8), - ); - } }; const LinuxThreadImpl = struct {