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...
pull/8912/head^2
Mitchell Hashimoto 2025-10-21 20:55:54 -07:00 committed by GitHub
parent 3548acfac6
commit 9dc2e5978f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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;
},
};