lib-vt: enable freestanding wasm builds (#9301)

This makes `libghostty-vt` build for freestanding wasm targets (aka a
browser) and produce a `ghostty-vt.wasm` file. This exports the same C
API that libghostty-vt does.

This commit specifically makes the changes necessary for the build to
build properly and for us to run the build in CI. We don't yet actually
try using it...
This commit is contained in:
Mitchell Hashimoto
2025-10-21 20:55:54 -07:00
committed by GitHub
parent 3548acfac6
commit 9dc2e5978f
10 changed files with 94 additions and 109 deletions

View File

@@ -204,6 +204,7 @@ jobs:
aarch64-linux,
x86_64-linux,
x86_64-windows,
wasm32-freestanding,
]
runs-on: namespace-profile-ghostty-sm
needs: test

View File

@@ -101,10 +101,19 @@ pub fn build(b: *std.Build) !void {
);
// libghostty-vt
const libghostty_vt_shared = try buildpkg.GhosttyLibVt.initShared(
b,
&mod,
);
const libghostty_vt_shared = shared: {
if (config.target.result.cpu.arch.isWasm()) {
break :shared try buildpkg.GhosttyLibVt.initWasm(
b,
&mod,
);
}
break :shared try buildpkg.GhosttyLibVt.initShared(
b,
&mod,
);
};
libghostty_vt_shared.install(libvt_step);
libghostty_vt_shared.install(b.getInstallStep());

View File

@@ -173,7 +173,13 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config {
bool,
"simd",
"Build with SIMD-accelerated code paths. Results in significant performance improvements.",
) orelse true;
) orelse simd: {
// We can't build our SIMD dependencies for Wasm. Note that we may
// still use SIMD features in the Wasm-builds.
if (target.result.cpu.arch.isWasm()) break :simd false;
break :simd true;
};
config.wayland = b.option(
bool,

View File

@@ -1,6 +1,7 @@
const GhosttyLibVt = @This();
const std = @import("std");
const assert = std.debug.assert;
const RunStep = std.Build.Step.Run;
const Config = @import("Config.zig");
const GhosttyZig = @import("GhosttyZig.zig");
@@ -17,7 +18,35 @@ artifact: *std.Build.Step.InstallArtifact,
/// The final library file
output: std.Build.LazyPath,
dsym: ?std.Build.LazyPath,
pkg_config: std.Build.LazyPath,
pkg_config: ?std.Build.LazyPath,
pub fn initWasm(
b: *std.Build,
zig: *const GhosttyZig,
) !GhosttyLibVt {
const target = zig.vt.resolved_target.?;
assert(target.result.cpu.arch.isWasm());
const exe = b.addExecutable(.{
.name = "ghostty-vt",
.root_module = zig.vt_c,
.version = std.SemanticVersion{ .major = 0, .minor = 1, .patch = 0 },
});
// Allow exported symbols to actually be exported.
exe.rdynamic = true;
// There is no entrypoint for this wasm module.
exe.entry = .disabled;
return .{
.step = &exe.step,
.artifact = b.addInstallArtifact(exe, .{}),
.output = exe.getEmittedBin(),
.dsym = null,
.pkg_config = null,
};
}
pub fn initShared(
b: *std.Build,
@@ -82,9 +111,11 @@ pub fn install(
) void {
const b = step.owner;
step.dependOn(&self.artifact.step);
step.dependOn(&b.addInstallFileWithDir(
self.pkg_config,
.prefix,
"share/pkgconfig/libghostty-vt.pc",
).step);
if (self.pkg_config) |pkg_config| {
step.dependOn(&b.addInstallFileWithDir(
pkg_config,
.prefix,
"share/pkgconfig/libghostty-vt.pc",
).step);
}
}

View File

@@ -20,11 +20,17 @@ pub fn default(c_alloc_: ?*const Allocator) std.mem.Allocator {
// If we're given an allocator, use it.
if (c_alloc_) |c_alloc| return c_alloc.zig();
// Tests always use the test allocator so we can detect leaks.
if (comptime builtin.is_test) return testing.allocator;
// If we have libc, use that. We prefer libc if we have it because
// its generally fast but also lets the embedder easily override
// malloc/free with custom allocators like mimalloc or something.
if (comptime builtin.link_libc) return std.heap.c_allocator;
// Wasm
if (comptime builtin.target.cpu.arch.isWasm()) return std.heap.wasm_allocator;
// No libc, use the preferred allocator for releases which is the
// Zig SMP allocator.
return std.heap.smp_allocator;

View File

@@ -9,6 +9,9 @@
//! this in the future.
const lib = @This();
const std = @import("std");
const builtin = @import("builtin");
// The public API below reproduces a lot of terminal/main.zig but
// is separate because (1) we need our root file to be in `src/`
// so we can access other directories and (2) we may want to withhold
@@ -126,6 +129,26 @@ comptime {
}
}
pub const std_options: std.Options = options: {
if (builtin.target.cpu.arch.isWasm()) break :options .{
// Wasm builds we specifically want to optimize for space with small
// releases so we bump up to warn. Everything else acts pretty normal.
.log_level = switch (builtin.mode) {
.Debug => .debug,
.ReleaseSmall => .warn,
else => .info,
},
// Wasm doesn't have access to stdio so we have a custom log function.
.logFn = @import("os/wasm/log.zig").log,
};
// For everything else we currently use defaults. Longer term I'm
// SURE this isn't right (e.g. we definitely want to customize the log
// function for the C lib at least).
break :options .{};
};
test {
_ = terminal;
_ = @import("lib/main.zig");

View File

@@ -23,93 +23,3 @@ pub const alloc = if (builtin.is_test)
std.testing.allocator
else
std.heap.wasm_allocator;
/// For host-owned allocations:
/// We need to keep track of our own pointer lengths because Zig
/// allocators usually don't do this and we need to be able to send
/// a direct pointer back to the host system. A more appropriate thing
/// to do would be to probably make a custom allocator that keeps track
/// of size.
var allocs: std.AutoHashMapUnmanaged([*]u8, usize) = .{};
/// Allocate len bytes and return a pointer to the memory in the host.
/// The data is not zeroed.
pub export fn malloc(len: usize) ?[*]u8 {
return alloc_(len) catch return null;
}
fn alloc_(len: usize) ![*]u8 {
// Create the allocation
const slice = try alloc.alloc(u8, len);
errdefer alloc.free(slice);
// Store the size so we can deallocate later
try allocs.putNoClobber(alloc, slice.ptr, slice.len);
errdefer _ = allocs.remove(slice.ptr);
return slice.ptr;
}
/// Free an allocation from malloc.
pub export fn free(ptr: ?[*]u8) void {
if (ptr) |v| {
if (allocs.get(v)) |len| {
const slice = v[0..len];
alloc.free(slice);
_ = allocs.remove(v);
}
}
}
/// Convert an allocated pointer of any type to a host-owned pointer.
/// This pushes the responsibility to free it to the host. The returned
/// pointer will match the pointer but is typed correctly for returning
/// to the host.
pub fn toHostOwned(ptr: anytype) ![*]u8 {
// Convert our pointer to a byte array
const info = @typeInfo(@TypeOf(ptr)).pointer;
const T = info.child;
const size = @sizeOf(T);
const casted = @as([*]u8, @ptrFromInt(@intFromPtr(ptr)));
// Store the information about it
try allocs.putNoClobber(alloc, casted, size);
errdefer _ = allocs.remove(casted);
return casted;
}
/// Returns true if the value is host owned.
pub fn isHostOwned(ptr: anytype) bool {
const casted = @as([*]u8, @ptrFromInt(@intFromPtr(ptr)));
return allocs.contains(casted);
}
/// Convert a pointer back to a module-owned value. The caller is expected
/// to cast or have the valid pointer for alloc calls.
pub fn toModuleOwned(ptr: anytype) void {
const casted = @as([*]u8, @ptrFromInt(@intFromPtr(ptr)));
_ = allocs.remove(casted);
}
test "basics" {
const testing = std.testing;
const buf = malloc(32).?;
try testing.expect(allocs.size == 1);
free(buf);
try testing.expect(allocs.size == 0);
}
test "toHostOwned" {
const testing = std.testing;
const Point = struct { x: u32 = 0, y: u32 = 0 };
const p = try alloc.create(Point);
errdefer alloc.destroy(p);
const ptr = try toHostOwned(p);
try testing.expect(allocs.size == 1);
try testing.expect(isHostOwned(p));
try testing.expect(isHostOwned(ptr));
free(ptr);
try testing.expect(allocs.size == 0);
}

View File

@@ -1,15 +1,12 @@
const std = @import("std");
const builtin = @import("builtin");
const wasm = @import("../wasm.zig");
const wasm_target = @import("target.zig");
// Use the correct implementation
pub const log = if (wasm_target.target) |target| switch (target) {
.browser => Browser.log,
} else @compileError("wasm target required");
pub const log = Freestanding.log;
/// Browser implementation calls an extern "log" function.
pub const Browser = struct {
/// Freestanding implementation calls an extern "log" function.
pub const Freestanding = struct {
// The function std.log will call.
pub fn log(
comptime level: std.log.Level,

View File

@@ -10,7 +10,7 @@ pub const Target = enum {
};
/// Our specific target platform.
pub const target: ?Target = if (!builtin.target.isWasm()) null else target: {
pub const target: ?Target = if (!builtin.target.cpu.arch.isWasm()) null else target: {
const result = @as(Target, @enumFromInt(@intFromEnum(options.wasm_target)));
// This maybe isn't necessary but I don't know if enums without a specific
// tag type and value are guaranteed to be the same between build.zig

View File

@@ -123,7 +123,9 @@ pub fn encode(
encoder_.?.opts,
) catch unreachable;
out_written.* = discarding.count;
// Discarding always uses a u64. If we're on 32-bit systems
// we cast down. We should make this safer in the future.
out_written.* = @intCast(discarding.count);
return .out_of_memory;
},
};