mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-06 07:38:21 +00:00
libghostty: export function table and make it growable for wasm (#12003)
Replaces #11958 This exports the function table and makes it growable so that the effects API can be used. It's still very not ergonomic to use the effects API so I'm going to work on that next, but this at least makes it _possible_. Zig 0.15.x is missing the ability to pass `--growable-table` to the linker so we use binary patching to add it (yay!) lol.
This commit is contained in:
@@ -16,6 +16,11 @@ A file for [guiding coding agents](https://agents.md/).
|
||||
- **Formatting (Swift)**: `swiftlint lint --strict --fix`
|
||||
- **Formatting (other)**: `prettier -w .`
|
||||
|
||||
## libghostty-vt
|
||||
|
||||
- Build: `zig build -Demit-lib-vt`
|
||||
- Build WASM: `zig build -Demit-lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall`
|
||||
|
||||
## Directory Structure
|
||||
|
||||
- Shared Zig core: `src/`
|
||||
|
||||
@@ -9,8 +9,8 @@ const GhosttyZig = @import("GhosttyZig.zig");
|
||||
/// The step that generates the file.
|
||||
step: *std.Build.Step,
|
||||
|
||||
/// The artifact result
|
||||
artifact: *std.Build.Step.InstallArtifact,
|
||||
/// The install step for the library output.
|
||||
artifact: *std.Build.Step,
|
||||
|
||||
/// The kind of library
|
||||
kind: Kind,
|
||||
@@ -44,14 +44,40 @@ pub fn initWasm(
|
||||
// Allow exported symbols to actually be exported.
|
||||
exe.rdynamic = true;
|
||||
|
||||
// Export the indirect function table so that embedders (e.g. JS in
|
||||
// a browser) can insert callback entries for terminal effects.
|
||||
exe.export_table = true;
|
||||
|
||||
// There is no entrypoint for this wasm module.
|
||||
exe.entry = .disabled;
|
||||
|
||||
// Zig's WASM linker doesn't support --growable-table, so the table
|
||||
// is emitted with max == min and can't be grown from JS. Run a
|
||||
// small Zig build tool that patches the binary's table section to
|
||||
// remove the max limit.
|
||||
const patch_run = patch: {
|
||||
const patcher = b.addExecutable(.{
|
||||
.name = "wasm_patch_growable_table",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/build/wasm_patch_growable_table.zig"),
|
||||
.target = b.graph.host,
|
||||
}),
|
||||
});
|
||||
break :patch b.addRunArtifact(patcher);
|
||||
};
|
||||
patch_run.addFileArg(exe.getEmittedBin());
|
||||
const output = patch_run.addOutputFileArg("ghostty-vt.wasm");
|
||||
const artifact_install = b.addInstallFileWithDir(
|
||||
output,
|
||||
.bin,
|
||||
"ghostty-vt.wasm",
|
||||
);
|
||||
|
||||
return .{
|
||||
.step = &exe.step,
|
||||
.artifact = b.addInstallArtifact(exe, .{}),
|
||||
.step = &patch_run.step,
|
||||
.artifact = &artifact_install.step,
|
||||
.kind = .wasm,
|
||||
.output = exe.getEmittedBin(),
|
||||
.output = output,
|
||||
.dsym = null,
|
||||
.pkg_config = null,
|
||||
};
|
||||
@@ -164,7 +190,7 @@ fn initLib(
|
||||
|
||||
return .{
|
||||
.step = &lib.step,
|
||||
.artifact = b.addInstallArtifact(lib, .{}),
|
||||
.artifact = &b.addInstallArtifact(lib, .{}).step,
|
||||
.kind = kind,
|
||||
.output = lib.getEmittedBin(),
|
||||
.dsym = dsymutil,
|
||||
@@ -177,7 +203,7 @@ pub fn install(
|
||||
step: *std.Build.Step,
|
||||
) void {
|
||||
const b = step.owner;
|
||||
step.dependOn(&self.artifact.step);
|
||||
step.dependOn(self.artifact);
|
||||
if (self.pkg_config) |pkg_config| {
|
||||
step.dependOn(&b.addInstallFileWithDir(
|
||||
pkg_config,
|
||||
|
||||
269
src/build/wasm_patch_growable_table.zig
Normal file
269
src/build/wasm_patch_growable_table.zig
Normal file
@@ -0,0 +1,269 @@
|
||||
//! Build tool that patches a WASM binary to make the function table
|
||||
//! growable by removing its maximum size limit.
|
||||
//!
|
||||
//! Zig's WASM linker doesn't support `--growable-table`, so the table
|
||||
//! is emitted with max == min. This tool finds the table section (id 4)
|
||||
//! and changes the limits flag from 0x01 (has max) to 0x00 (no max),
|
||||
//! removing the max field.
|
||||
//!
|
||||
//! Usage: wasm_growable_table <input.wasm> <output.wasm>
|
||||
|
||||
const std = @import("std");
|
||||
const testing = std.testing;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
pub fn main() !void {
|
||||
// This is a one-off patcher, so we leak all our memory on purpose
|
||||
// and let the OS clean it up when we exit.
|
||||
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
|
||||
const alloc = gpa.allocator();
|
||||
|
||||
// Parse args: program input output
|
||||
const args = try std.process.argsAlloc(alloc);
|
||||
defer std.process.argsFree(alloc, args);
|
||||
if (args.len != 3) {
|
||||
std.log.err("usage: wasm_growable_table <input.wasm> <output.wasm>", .{});
|
||||
std.process.exit(1);
|
||||
unreachable;
|
||||
}
|
||||
|
||||
// Patch the file.
|
||||
const output: []const u8 = try patchTableGrowable(
|
||||
alloc,
|
||||
try std.fs.cwd().readFileAlloc(
|
||||
alloc,
|
||||
args[1],
|
||||
std.math.maxInt(usize),
|
||||
),
|
||||
);
|
||||
|
||||
// Write our output
|
||||
const out_file = try std.fs.cwd().createFile(args[2], .{});
|
||||
defer out_file.close();
|
||||
try out_file.writeAll(output);
|
||||
}
|
||||
|
||||
/// Patch the WASM binary's table section to remove the maximum size
|
||||
/// limit, making the table growable. If the table already has no max
|
||||
/// or no table section is found, the input is returned unchanged.
|
||||
///
|
||||
/// The WASM table section (id=4) encodes table limits as:
|
||||
/// - flags=0x00, min (LEB128) — no max, growable
|
||||
/// - flags=0x01, min (LEB128), max (LEB128) — bounded, not growable
|
||||
///
|
||||
/// This function rewrites the section to use flags=0x00, dropping the
|
||||
/// max field entirely.
|
||||
fn patchTableGrowable(
|
||||
alloc: Allocator,
|
||||
input: []const u8,
|
||||
) (error{InvalidWasm} || std.Io.Writer.Error)![]const u8 {
|
||||
if (input.len < 8) return error.InvalidWasm;
|
||||
|
||||
// Start after the 8-byte WASM header (magic + version).
|
||||
var pos: usize = 8;
|
||||
|
||||
while (pos < input.len) {
|
||||
const section_id = input[pos];
|
||||
pos += 1;
|
||||
const section_size = readLeb128(input, &pos);
|
||||
const section_start = pos;
|
||||
|
||||
// We're looking for section 4 (the table section).
|
||||
if (section_id != 4) {
|
||||
pos = section_start + section_size;
|
||||
continue;
|
||||
}
|
||||
|
||||
_ = readLeb128(input, &pos); // table count
|
||||
pos += 1; // elem_type (0x70 = funcref)
|
||||
const flags = input[pos];
|
||||
|
||||
// flags bit 0 indicates whether a max is present.
|
||||
if (flags & 1 == 0) {
|
||||
// Already no max, nothing to patch.
|
||||
return input;
|
||||
}
|
||||
|
||||
// Record positions of each field so we can reconstruct
|
||||
// the section without the max value.
|
||||
const flags_pos = pos;
|
||||
pos += 1; // skip flags byte
|
||||
const min_start = pos;
|
||||
_ = readLeb128(input, &pos); // min
|
||||
const max_start = pos;
|
||||
_ = readLeb128(input, &pos); // max
|
||||
const max_end = pos;
|
||||
const section_end = section_start + section_size;
|
||||
|
||||
// Build the new section payload with the max removed:
|
||||
// [table count + elem_type] [flags=0x00] [min] [trailing data]
|
||||
var payload: std.Io.Writer.Allocating = .init(alloc);
|
||||
try payload.writer.writeAll(input[section_start..flags_pos]);
|
||||
try payload.writer.writeByte(0x00); // flags: no max
|
||||
try payload.writer.writeAll(input[min_start..max_start]);
|
||||
try payload.writer.writeAll(input[max_end..section_end]);
|
||||
|
||||
// Reassemble the full binary:
|
||||
// [everything before this section] [section id] [new size] [new payload] [everything after]
|
||||
const before_section = input[0 .. section_start - 1 - uleb128Size(section_size)];
|
||||
var result: std.Io.Writer.Allocating = .init(alloc);
|
||||
try result.writer.writeAll(before_section);
|
||||
try result.writer.writeByte(4); // table section id
|
||||
try result.writer.writeUleb128(@as(u32, @intCast(payload.written().len)));
|
||||
try result.writer.writeAll(payload.written());
|
||||
try result.writer.writeAll(input[section_end..]);
|
||||
return result.written();
|
||||
}
|
||||
|
||||
// No table section found; return input unchanged.
|
||||
return input;
|
||||
}
|
||||
|
||||
/// Decode an unsigned LEB128 value from `bytes` starting at `pos.*`,
|
||||
/// advancing `pos` past the encoded bytes.
|
||||
fn readLeb128(bytes: []const u8, pos: *usize) u32 {
|
||||
var result: u32 = 0;
|
||||
var shift: u5 = 0;
|
||||
while (true) {
|
||||
const byte = bytes[pos.*];
|
||||
pos.* += 1;
|
||||
result |= @as(u32, byte & 0x7f) << shift;
|
||||
if (byte & 0x80 == 0) return result;
|
||||
shift +%= 7;
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the number of bytes needed to encode `value` as unsigned LEB128.
|
||||
fn uleb128Size(value: u32) usize {
|
||||
var v = value;
|
||||
var size: usize = 0;
|
||||
while (true) {
|
||||
v >>= 7;
|
||||
size += 1;
|
||||
if (v == 0) return size;
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal valid WASM module with a bounded table (min=1, max=1).
|
||||
/// Sections: type(1), table(4), export(7).
|
||||
const test_wasm_bounded_table = [_]u8{
|
||||
0x00, 0x61, 0x73, 0x6d, // magic
|
||||
0x01, 0x00, 0x00, 0x00, // version
|
||||
// Type section (id=1): 1 type, () -> ()
|
||||
0x01, 0x04, 0x01, 0x60,
|
||||
0x00, 0x00,
|
||||
// Table section (id=4): 1 table, funcref, flags=1, min=1, max=1
|
||||
0x04, 0x05,
|
||||
0x01, 0x70, 0x01, 0x01,
|
||||
0x01,
|
||||
// Export section (id=7): 0 exports
|
||||
0x07, 0x01, 0x00,
|
||||
};
|
||||
|
||||
/// Same module but the table already has no max (flags=0).
|
||||
const test_wasm_growable_table = [_]u8{
|
||||
0x00, 0x61, 0x73, 0x6d, // magic
|
||||
0x01, 0x00, 0x00, 0x00, // version
|
||||
// Type section (id=1)
|
||||
0x01, 0x04, 0x01, 0x60,
|
||||
0x00, 0x00,
|
||||
// Table section (id=4): 1 table, funcref, flags=0, min=1
|
||||
0x04, 0x04,
|
||||
0x01, 0x70, 0x00, 0x01,
|
||||
// Export section (id=7): 0 exports
|
||||
0x07, 0x01, 0x00,
|
||||
};
|
||||
|
||||
/// Module with no table section at all.
|
||||
const test_wasm_no_table = [_]u8{
|
||||
0x00, 0x61, 0x73, 0x6d, // magic
|
||||
0x01, 0x00, 0x00, 0x00, // version
|
||||
// Type section (id=1)
|
||||
0x01, 0x04, 0x01, 0x60,
|
||||
0x00, 0x00,
|
||||
// Export section (id=7): 0 exports
|
||||
0x07, 0x01,
|
||||
0x00,
|
||||
};
|
||||
|
||||
test "patches bounded table to remove max" {
|
||||
// We use a non-checking allocator because the patched result is
|
||||
// intentionally leaked (matches the real main() usage).
|
||||
const result = try patchTableGrowable(
|
||||
std.heap.page_allocator,
|
||||
&test_wasm_bounded_table,
|
||||
);
|
||||
|
||||
// Result should differ from input (max was removed).
|
||||
try testing.expect(!std.mem.eql(
|
||||
u8,
|
||||
result,
|
||||
&test_wasm_bounded_table,
|
||||
));
|
||||
|
||||
// Find the table section in the output and verify flags=0x00.
|
||||
var pos: usize = 8;
|
||||
while (pos < result.len) {
|
||||
const id = result[pos];
|
||||
pos += 1;
|
||||
const size = readLeb128(result, &pos);
|
||||
if (id == 4) {
|
||||
_ = readLeb128(result, &pos); // table count
|
||||
pos += 1; // elem_type
|
||||
// flags should now be 0x00 (no max).
|
||||
try testing.expectEqual(@as(u8, 0x00), result[pos]);
|
||||
return;
|
||||
}
|
||||
pos += size;
|
||||
}
|
||||
return error.TableSectionNotFound;
|
||||
}
|
||||
|
||||
test "already growable table is returned unchanged" {
|
||||
const result = try patchTableGrowable(
|
||||
testing.allocator,
|
||||
&test_wasm_growable_table,
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as([*]const u8, &test_wasm_growable_table),
|
||||
result.ptr,
|
||||
);
|
||||
}
|
||||
|
||||
test "no table section returns input unchanged" {
|
||||
const result = try patchTableGrowable(
|
||||
testing.allocator,
|
||||
&test_wasm_no_table,
|
||||
);
|
||||
try testing.expectEqual(@as([*]const u8, &test_wasm_no_table), result.ptr);
|
||||
}
|
||||
|
||||
test "too short input returns InvalidWasm" {
|
||||
try testing.expectError(
|
||||
error.InvalidWasm,
|
||||
patchTableGrowable(testing.allocator, "short"),
|
||||
);
|
||||
}
|
||||
|
||||
test "readLeb128 single byte" {
|
||||
const bytes = [_]u8{0x05};
|
||||
var pos: usize = 0;
|
||||
try testing.expectEqual(@as(u32, 5), readLeb128(&bytes, &pos));
|
||||
try testing.expectEqual(@as(usize, 1), pos);
|
||||
}
|
||||
|
||||
test "readLeb128 multi byte" {
|
||||
// 300 = 0b100101100 → LEB128: 0xAC 0x02
|
||||
const bytes = [_]u8{ 0xAC, 0x02 };
|
||||
var pos: usize = 0;
|
||||
try testing.expectEqual(@as(u32, 300), readLeb128(&bytes, &pos));
|
||||
try testing.expectEqual(@as(usize, 2), pos);
|
||||
}
|
||||
|
||||
test "uleb128Size" {
|
||||
try testing.expectEqual(@as(usize, 1), uleb128Size(0));
|
||||
try testing.expectEqual(@as(usize, 1), uleb128Size(0x7f));
|
||||
try testing.expectEqual(@as(usize, 2), uleb128Size(0x80));
|
||||
try testing.expectEqual(@as(usize, 2), uleb128Size(300));
|
||||
try testing.expectEqual(@as(usize, 5), uleb128Size(std.math.maxInt(u32)));
|
||||
}
|
||||
Reference in New Issue
Block a user