From fd923cc15aebede3163c08ea96d1ba5edfe1e5cc Mon Sep 17 00:00:00 2001 From: Brezn Date: Fri, 29 May 2026 12:12:06 -0600 Subject: [PATCH 1/7] Update Zig compatibility from 0.15.2 to 0.16.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Zig standard library had a major restructuring in 0.16.0, primarily around the new Io abstraction. This updates all source files to use the new APIs: - std.process.getEnvVarOwned → b.graph.environ_map.get (build.zig) and std.process.Environ (runtime) - std.fs.cwd() → std.Io.Dir.cwd() - File methods (close, stat, writer, reader) now take io: Io parameter - std.fs.getAppDataDir removed; replaced with manual XDG/platform logic - std.process.execve → std.process.replace - Build module methods (addIncludePath, addCSourceFile, linkSystemLibrary) moved from Compile step to root_module - main() accepts std.process.Init for Io and Environ access Co-Authored-By: Claude Opus 4.6 (1M context) --- build.zig | 40 +++++------ lib/burrito.ex | 2 +- src/archiver.zig | 136 ++++++++++--------------------------- src/erlang_launcher.zig | 80 ++++++++-------------- src/install.zig | 11 +-- src/logger.zig | 5 +- src/maintenance.zig | 38 +++++------ src/wrapper.zig | 147 ++++++++++++++++++---------------------- 8 files changed, 177 insertions(+), 282 deletions(-) diff --git a/build.zig b/build.zig index 03e8125..941ddda 100644 --- a/build.zig +++ b/build.zig @@ -1,21 +1,12 @@ -//// -// DO NOT EDIT THIS FILE -//// - const std = @import("std"); const foilz = @import("src/archiver.zig"); const builtin = @import("builtin"); const log = std.log; -const CrossTarget = std.zig.CrossTarget; -const Mode = std.builtin.Mode; -const LibExeObjStep = std.build.LibExeObjStep; - pub fn build(b: *std.Build) !void { log.info("Zig is building an Elixir binary... ⚡", .{}); - // Run build steps! try run_archiver(b); try build_wrapper(b); @@ -25,7 +16,8 @@ pub fn build(b: *std.Build) !void { pub fn run_archiver(b: *std.Build) !void { log.info("Generating and compressing release payload... 📦", .{}); - const release_path = try std.process.getEnvVarOwned(b.allocator, "__BURRITO_RELEASE_PATH"); + const release_path = b.graph.environ_map.get("__BURRITO_RELEASE_PATH") orelse + return error.MissingEnv; try foilz.pack_directory(b.allocator, release_path, "./payload.foilz"); if (builtin.os.tag == .windows) { @@ -46,19 +38,20 @@ pub fn run_archiver(b: *std.Build) !void { pub fn build_wrapper(b: *std.Build) !void { log.info("Building wrapper and embedding payload... 🌯", .{}); - const release_name = try std.process.getEnvVarOwned(b.allocator, "__BURRITO_RELEASE_NAME"); - const plugin_path = std.process.getEnvVarOwned(b.allocator, "__BURRITO_PLUGIN_PATH") catch null; - const is_prod = std.process.getEnvVarOwned(b.allocator, "__BURRITO_IS_PROD") catch "1"; - const musl_runtime_path = std.process.getEnvVarOwned(b.allocator, "__BURRITO_MUSL_RUNTIME_PATH") catch ""; + const release_name = b.graph.environ_map.get("__BURRITO_RELEASE_NAME") orelse + return error.MissingEnv; + const plugin_path = b.graph.environ_map.get("__BURRITO_PLUGIN_PATH"); + const is_prod = b.graph.environ_map.get("__BURRITO_IS_PROD") orelse "1"; + const musl_runtime_path = b.graph.environ_map.get("__BURRITO_MUSL_RUNTIME_PATH") orelse ""; var opt_level = std.builtin.OptimizeMode.Debug; if (std.mem.eql(u8, is_prod, "1")) { opt_level = std.builtin.OptimizeMode.ReleaseSmall; } - var file = try std.fs.cwd().openFile("payload.foilz", .{}); - defer file.close(); - const uncompressed_size = try file.getEndPos(); + var file = try std.Io.Dir.cwd().openFile(b.graph.io, "payload.foilz", .{}); + defer file.close(b.graph.io); + const uncompressed_size = try file.length(b.graph.io); const target = b.standardTargetOptions(.{}); const wrapper_exe = b.addExecutable(.{ @@ -82,11 +75,10 @@ pub fn build_wrapper(b: *std.Build) !void { exe_options.addOption([]const u8, "MUSL_RUNTIME_PATH", musl_runtime_path); if (target.result.os.tag == .windows) { - wrapper_exe.addIncludePath(b.path("src/")); + wrapper_exe.root_module.addIncludePath(b.path("src/")); } - // Link standard C libary to the wrapper - wrapper_exe.linkSystemLibrary("c"); + wrapper_exe.root_module.linkSystemLibrary("c", .{}); if (plugin_path) |plugin| { log.info("Plugin found! {s} 🔌", .{plugin}); @@ -101,10 +93,10 @@ pub fn build_wrapper(b: *std.Build) !void { wrapper_exe.root_module.addImport("burrito_plugin", plug_mod); } - wrapper_exe.addIncludePath(b.path("src/xz")); - wrapper_exe.addCSourceFile(.{ .file = b.path("src/xz/xz_crc32.c") }); - wrapper_exe.addCSourceFile(.{ .file = b.path("src/xz/xz_dec_lzma2.c") }); - wrapper_exe.addCSourceFile(.{ .file = b.path("src/xz/xz_dec_stream.c") }); + wrapper_exe.root_module.addIncludePath(b.path("src/xz")); + wrapper_exe.root_module.addCSourceFile(.{ .file = b.path("src/xz/xz_crc32.c") }); + wrapper_exe.root_module.addCSourceFile(.{ .file = b.path("src/xz/xz_dec_lzma2.c") }); + wrapper_exe.root_module.addCSourceFile(.{ .file = b.path("src/xz/xz_dec_stream.c") }); b.installArtifact(wrapper_exe); } diff --git a/lib/burrito.ex b/lib/burrito.ex index 64d1a88..df0f80a 100644 --- a/lib/burrito.ex +++ b/lib/burrito.ex @@ -2,7 +2,7 @@ defmodule Burrito do alias Burrito.Builder alias Burrito.Builder.Log - @zig_version_expected %Version{major: 0, minor: 15, patch: 2} + @zig_version_expected %Version{major: 0, minor: 16, patch: 0} @openssl_version %Version{major: 3, minor: 5, patch: 1} @musl_version %Version{major: 1, minor: 2, patch: 5} diff --git a/src/archiver.zig b/src/archiver.zig index 6675f62..7474e4d 100644 --- a/src/archiver.zig +++ b/src/archiver.zig @@ -1,45 +1,11 @@ -///// -// This is a packing/unpacking utility used to pack up a elixir mix release into "FOILZ" archive. -// The structure of the FOILZ archive file is very simple, and akin to a very basic TAR archive: -// -// ┌────────────────────────┐ -// │ │ -// │ Magic Header: 'FOILZ' │ -// │ │ -// ├────────────────────────┤ -// ┌──────── │ u64 File Path Len │◄───────── Informs how long the string following will be -// │ ├────────────────────────┤ -// │ │ │ -// │ │ File Path Characters │◄───────── File path in release dir + file name -// File Record ────┤ │ │ -// │ ├────────────────────────┤ -// │ │ u64 File Byte Len │◄───────── Informs how long the file bytes following will be -// │ ├────────────────────────┤ -// │ │ │ -// │ │ File Bytes │◄───────── Raw bytes of file -// │ │ │ -// │ ├────────────────────────┤ -// └──────── │ usize File Mode │◄───────── POSIX File Mode (Ignored on Windows) -// ├────────────────────────┤ -// │ │ -// │ Magic Trailer: 'FOILZ' │ -// │ │ -// └────────────────────────┘ -// -// There can be many file records inside a FOILZ archive, after packing, it is gzip or xz compressed. -// At runtime, we decompress it in memory and write the files to disk in a common location. -///// - const builtin = @import("builtin"); const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; -const fs = std.fs; +const Io = std.Io; const log = std.log; const mem = std.mem; -const os = std.os; -const gzip = std.compress.gzip; const xz = @cImport(@cInclude("xz.h")); @@ -47,16 +13,17 @@ const MAGIC = "FOILZ"; const MAX_READ_SIZE = 1000000000; pub fn pack_directory(arena: Allocator, path: []const u8, archive_path: []const u8) anyerror!void { - // Open a file for the archive - const arch_file = try fs.cwd().createFile(archive_path, .{ .truncate = true }); - defer arch_file.close(); + const io = std.Options.debug_io; + + const arch_file = try Io.Dir.cwd().createFile(io, archive_path, .{ .truncate = true }); + defer arch_file.close(io); var foilz_write_buf: [1024]u8 = undefined; - var foilz_writer = arch_file.writer(&foilz_write_buf); + var foilz_writer = arch_file.writer(io, &foilz_write_buf); const writer = &foilz_writer.interface; - var dir = try fs.openDirAbsolute(path, .{ .access_sub_paths = true, .iterate = true }); - defer dir.close(); + var dir = try Io.Dir.openDirAbsolute(io, path, .{ .access_sub_paths = true, .iterate = true }); + defer dir.close(io); var walker = try dir.walk(arena); defer walker.deinit(); @@ -65,28 +32,24 @@ pub fn pack_directory(arena: Allocator, path: []const u8, archive_path: []const try writer.writeAll(MAGIC); - while (try walker.next()) |entry| { + while (try walker.next(io)) |entry| { if (entry.kind == .file) { - // Replace some path string data for the tar index name - // specifically replace: '../_build/prod/rel/' --> '' - // This just makes it easier to write the files out later on the destination machine const needle = path; const replacement = ""; const replacement_size = mem.replacementSize(u8, entry.path, needle, replacement); - var dest_buff: [fs.max_path_bytes]u8 = undefined; + var dest_buff: [std.fs.max_path_bytes]u8 = undefined; const index = dest_buff[0..replacement_size]; _ = mem.replace(u8, entry.path, needle, replacement, index); - const file = try entry.dir.openFile(entry.basename, .{}); - defer file.close(); + const file = try entry.dir.openFile(io, entry.basename, .{}); + defer file.close(io); var read_buf: [1024]u8 = undefined; - var file_reader = file.reader(&read_buf); + var file_reader = file.reader(io, &read_buf); const reader = &file_reader.interface; - const stat = try file.stat(); + const stat = try file.stat(io); - // Write file record to archive const name = index; try writer.writeInt(u64, name.len, .little); try writer.writeAll(name); @@ -94,7 +57,7 @@ pub fn pack_directory(arena: Allocator, path: []const u8, archive_path: []const if (stat.size > 0) { assert(stat.size == try reader.streamRemaining(writer)); } - try writer.writeInt(usize, stat.mode, .little); + try writer.writeInt(usize, @intCast(stat.permissions.toMode()), .little); count += 1; @@ -103,17 +66,13 @@ pub fn pack_directory(arena: Allocator, path: []const u8, archive_path: []const } direct_log("\n", .{}); - // Log success - try writer.writeAll(MAGIC); try writer.flush(); log.info("Archived {} files into payload! 📥", .{count}); } -pub fn unpack_files(arena: Allocator, data: []const u8, dest_path: []const u8, uncompressed_size: u64) !void { - // Decompress the data in the payload - +pub fn unpack_files(io: Io, arena: Allocator, data: []const u8, dest_path: []const u8, uncompressed_size: u64) !void { var decompressed: []u8 = try arena.alloc(u8, uncompressed_size); var xz_buffer: xz.xz_buf = .{ @@ -135,67 +94,45 @@ pub fn unpack_files(arena: Allocator, data: []const u8, dest_path: []const u8, u return error.ParseError; } - // Validate the header of the payload if (!std.mem.eql(u8, MAGIC, decompressed[0..5])) { return error.BadHeader; } - // We start at position 5 to skip the header var cursor: u64 = 5; var file_count: u64 = 0; - ////// - // Read until we reach the end of the trailer - // Look ahead 5 bytes and see while (cursor < decompressed.len - 5) { - ////// - // Read the file name const string_len = std.mem.readInt(u64, decompressed[cursor .. cursor + @sizeOf(u64)][0..8], .little); cursor = cursor + @sizeOf(u64); const file_name = decompressed[cursor .. cursor + string_len]; cursor = cursor + string_len; - ////// - // Read the file data from the payload const file_len = std.mem.readInt(u64, decompressed[cursor .. cursor + @sizeOf(u64)][0..8], .little); cursor = cursor + @sizeOf(u64); const file_data = decompressed[cursor .. cursor + file_len]; cursor = cursor + file_len; - ////// - // Read the mode for this file const file_mode = std.mem.readInt(usize, decompressed[cursor .. cursor + @sizeOf(usize)][0..@sizeOf(usize)], .little); cursor = cursor + @sizeOf(usize); - ////// - // Write the file - const full_file_path = try fs.path.join(arena, &[_][]const u8{ dest_path[0..], file_name }); + const full_file_path = try std.fs.path.join(arena, &[_][]const u8{ dest_path[0..], file_name }); - ////// - // Create any directories needed - const dir_name = fs.path.dirname(file_name); - if (dir_name != null) try create_dirs(dest_path[0..], dir_name.?, arena); + const dir_name = std.fs.path.dirname(file_name); + if (dir_name != null) try create_dirs(io, dest_path[0..], dir_name.?, arena); log.debug("Unpacked File: {s}", .{full_file_path}); - ////// - // Write the file to disk! - - // If we're on windows don't try and use file_mode because NTFS doesn't have that! - if (builtin.os.tag == .windows) { - const file = try fs.createFileAbsolute(full_file_path, .{ .truncate = true }); + { + const file = try Io.Dir.cwd().createFile(io, full_file_path, .{ .truncate = true }); if (file_len > 0) { - try file.writeAll(file_data); + try file.writePositionalAll(io, file_data, 0); } - file.close(); - } else { - const file = try fs.createFileAbsolute(full_file_path, .{ .truncate = true, .mode = @intCast(file_mode) }); - if (file_len > 0) { - try file.writeAll(file_data); + if (builtin.os.tag != .windows) { + try file.setPermissions(io, Io.File.Permissions.fromMode(@intCast(file_mode))); } - file.close(); + file.close(io); } file_count = file_count + 1; @@ -204,13 +141,13 @@ pub fn unpack_files(arena: Allocator, data: []const u8, dest_path: []const u8, u log.debug("Unpacked {} files", .{file_count}); } -fn create_dirs(dest_path: []const u8, sub_dir_names: []const u8, allocator: Allocator) !void { - var iterator = try fs.path.componentIterator(sub_dir_names); - var full_dir_path = try fs.path.join(allocator, &[_][]const u8{ dest_path, "" }); +fn create_dirs(io: Io, dest_path: []const u8, sub_dir_names: []const u8, allocator: Allocator) !void { + var iterator = std.fs.path.componentIterator(sub_dir_names); + var full_dir_path = try std.fs.path.join(allocator, &[_][]const u8{ dest_path, "" }); while (iterator.next()) |sub_dir| { - full_dir_path = try fs.path.join(allocator, &[_][]const u8{ full_dir_path, sub_dir.name }); - fs.makeDirAbsolute(full_dir_path) catch |err| { + full_dir_path = try std.fs.path.join(allocator, &[_][]const u8{ full_dir_path, sub_dir.name }); + Io.Dir.cwd().createDir(io, full_dir_path, .default_dir) catch |err| { switch (err) { error.PathAlreadyExists => { log.debug("Directory Exists: {s}", .{full_dir_path}); @@ -223,13 +160,10 @@ fn create_dirs(dest_path: []const u8, sub_dir_names: []const u8, allocator: Allo } } -// Adapted from `std.log`, but without forcing a newline fn direct_log(comptime message: []const u8, args: anytype) void { - var buffer: [64]u8 = undefined; - const stderr = std.debug.lockStderrWriter(&buffer); - defer std.debug.unlockStderrWriter(); - nosuspend { - stderr.print(message, args) catch return; - stderr.flush() catch return; - } + var buf: [64]u8 = undefined; + var w = Io.File.stderr().writer(std.Options.debug_io, &buf); + const writer = &w.interface; + writer.print(message, args) catch return; + writer.flush() catch return; } diff --git a/src/erlang_launcher.zig b/src/erlang_launcher.zig index a38ea7d..feea163 100644 --- a/src/erlang_launcher.zig +++ b/src/erlang_launcher.zig @@ -1,13 +1,11 @@ const std = @import("std"); const builtin = @import("builtin"); -const fs = std.fs; +const Io = std.Io; const log = std.log; const metadata = @import("metadata.zig"); -const win_asni = @cImport(@cInclude("win_ansi_fix.h")); const MetaStruct = metadata.MetaStruct; -const EnvMap = std.process.EnvMap; const MAX_READ_SIZE = 256; @@ -19,39 +17,32 @@ fn get_erl_exe_name() []const u8 { } } -pub fn launch(install_dir: []const u8, env_map: *EnvMap, meta: *const MetaStruct, self_path: []const u8, args_trimmed: []const []const u8) !void { +pub fn launch(io: Io, install_dir: []const u8, env_map: *std.process.Environ.Map, meta: *const MetaStruct, self_path: []const u8, args_trimmed: []const []const u8) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); const allocator = arena.allocator(); - // Computer directories we care about - const release_cookie_path = try fs.path.join(allocator, &[_][]const u8{ install_dir, "releases", "COOKIE" }); - const release_lib_path = try fs.path.join(allocator, &[_][]const u8{ install_dir, "lib" }); - const install_vm_args_path = try fs.path.join(allocator, &[_][]const u8{ install_dir, "releases", meta.app_version, "vm.args" }); - const config_sys_path = try fs.path.join(allocator, &[_][]const u8{ install_dir, "releases", meta.app_version, "sys.config" }); - const config_sys_path_no_ext = try fs.path.join(allocator, &[_][]const u8{ install_dir, "releases", meta.app_version, "sys" }); - const rel_vsn_dir = try fs.path.join(allocator, &[_][]const u8{ install_dir, "releases", meta.app_version }); - const boot_path = try fs.path.join(allocator, &[_][]const u8{ rel_vsn_dir, "start" }); + const release_cookie_path = try std.fs.path.join(allocator, &[_][]const u8{ install_dir, "releases", "COOKIE" }); + const release_lib_path = try std.fs.path.join(allocator, &[_][]const u8{ install_dir, "lib" }); + const install_vm_args_path = try std.fs.path.join(allocator, &[_][]const u8{ install_dir, "releases", meta.app_version, "vm.args" }); + const config_sys_path = try std.fs.path.join(allocator, &[_][]const u8{ install_dir, "releases", meta.app_version, "sys.config" }); + const config_sys_path_no_ext = try std.fs.path.join(allocator, &[_][]const u8{ install_dir, "releases", meta.app_version, "sys" }); + const rel_vsn_dir = try std.fs.path.join(allocator, &[_][]const u8{ install_dir, "releases", meta.app_version }); + const boot_path = try std.fs.path.join(allocator, &[_][]const u8{ rel_vsn_dir, "start" }); const erts_version_name = try std.fmt.allocPrint(allocator, "erts-{s}", .{meta.erts_version}); - const erts_bin_path = try fs.path.join(allocator, &[_][]const u8{ install_dir, erts_version_name, "bin" }); - const erl_bin_path = try fs.path.join(allocator, &[_][]const u8{ erts_bin_path, get_erl_exe_name() }); + const erts_bin_path = try std.fs.path.join(allocator, &[_][]const u8{ install_dir, erts_version_name, "bin" }); + const erl_bin_path = try std.fs.path.join(allocator, &[_][]const u8{ erts_bin_path, get_erl_exe_name() }); - // Read the Erlang COOKIE file for the release - const release_cookie_file = try fs.openFileAbsolute(release_cookie_path, .{ .mode = .read_write }); - var release_cookie_content = try release_cookie_file.readToEndAlloc(allocator, MAX_READ_SIZE); + const release_cookie_file = try Io.Dir.openFileAbsolute(io, release_cookie_path, .{ .mode = .read_write }); + defer release_cookie_file.close(io); + var read_buf: [1024]u8 = undefined; + var cookie_reader = release_cookie_file.reader(io, &read_buf); + var release_cookie_content: []const u8 = try cookie_reader.interface.allocRemaining(allocator, @enumFromInt(MAX_READ_SIZE)); - // Override the cookie if the env variable RELEASE_COOKIE is defined - const maybe_cookie = std.process.getEnvVarOwned(allocator, "RELEASE_COOKIE") catch |err| switch (err) { - error.EnvironmentVariableNotFound => null, - else => return err, - }; - - if (maybe_cookie) |cookie| { + if (env_map.get("RELEASE_COOKIE")) |cookie| { release_cookie_content = cookie; } - // Set all the required release arguments - const erlang_cli = &[_][]const u8{ erl_bin_path[0..], "-elixir ansi_enabled true", @@ -73,8 +64,6 @@ pub fn launch(install_dir: []const u8, env_map: *EnvMap, meta: *const MetaStruct }; if (builtin.os.tag == .windows) { - // Fix up Windows 10+ consoles having ANSI escape support, but only if we set some flags - win_asni.enable_virtual_term(); const final_args = try std.mem.concat(allocator, []const u8, &.{ erlang_cli, args_trimmed }); try env_map.put("RELEASE_ROOT", install_dir); @@ -101,33 +90,24 @@ pub fn launch(install_dir: []const u8, env_map: *EnvMap, meta: *const MetaStruct log.debug("CLI List: {any}", .{final_args}); - var erl_env_map = EnvMap.init(allocator); - defer erl_env_map.deinit(); - - var env_map_it = env_map.iterator(); - while (env_map_it.next()) |entry| { - const key = entry.key_ptr.*; - const val = entry.value_ptr.*; - try erl_env_map.put(key, val); - } - - try erl_env_map.put("ROOTDIR", install_dir[0..]); - try erl_env_map.put("BINDIR", erts_bin_path[0..]); - try erl_env_map.put("RELEASE_ROOT", install_dir); - try erl_env_map.put("RELEASE_SYS_CONFIG", config_sys_path_no_ext); - try erl_env_map.put("__BURRITO", "1"); - try erl_env_map.put("__BURRITO_BIN_PATH", self_path); + try env_map.put("ROOTDIR", install_dir[0..]); + try env_map.put("BINDIR", erts_bin_path[0..]); + try env_map.put("RELEASE_ROOT", install_dir); + try env_map.put("RELEASE_SYS_CONFIG", config_sys_path_no_ext); + try env_map.put("__BURRITO", "1"); + try env_map.put("__BURRITO_BIN_PATH", self_path); - // Extend LD_LIBRARY_PATH so NIF .so files can find system shared - // libraries (e.g. libgcc_s.so.1) when using a custom glibc ERTS const system_lib_paths = "/lib/x86_64-linux-gnu:/usr/lib/x86_64-linux-gnu:/lib:/usr/lib"; - if (erl_env_map.get("LD_LIBRARY_PATH")) |existing| { + if (env_map.get("LD_LIBRARY_PATH")) |existing| { const combined = try std.fmt.allocPrint(allocator, "{s}:{s}", .{ existing, system_lib_paths }); - try erl_env_map.put("LD_LIBRARY_PATH", combined); + try env_map.put("LD_LIBRARY_PATH", combined); } else { - try erl_env_map.put("LD_LIBRARY_PATH", system_lib_paths); + try env_map.put("LD_LIBRARY_PATH", system_lib_paths); } - return std.process.execve(allocator, final_args, &erl_env_map); + return std.process.replace(io, .{ + .argv = final_args, + .environ_map = env_map, + }); } } diff --git a/src/install.zig b/src/install.zig index 84be892..345696d 100644 --- a/src/install.zig +++ b/src/install.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const Io = std.Io; const metadata = @import("metadata.zig"); const MetaStruct = metadata.MetaStruct; @@ -12,16 +13,18 @@ pub const Install = struct { version: std.SemanticVersion = undefined, }; -pub fn load_install_from_path(allocator: std.mem.Allocator, full_install_path: []const u8) !?Install { +pub fn load_install_from_path(io: Io, allocator: std.mem.Allocator, full_install_path: []const u8) !?Install { const metadata_file_path = try std.fs.path.join(allocator, &[_][]const u8{ full_install_path, "_metadata.json" }); - const metadata_file = std.fs.openFileAbsolute(metadata_file_path, .{}) catch { + const metadata_file = Io.Dir.openFileAbsolute(io, metadata_file_path, .{}) catch { std.log.err("Failed to load the metadata file: {s}", .{metadata_file_path}); return null; }; - defer metadata_file.close(); + defer metadata_file.close(io); - const content = try metadata_file.readToEndAlloc(allocator, MAX_READ_SIZE); + var read_buf: [1024]u8 = undefined; + var file_reader = metadata_file.reader(io, &read_buf); + const content = try file_reader.interface.allocRemaining(allocator, @enumFromInt(MAX_READ_SIZE)); const metadata_struct = metadata.parse(allocator, content); if (metadata_struct == null) { diff --git a/src/logger.zig b/src/logger.zig index 9c9d08d..a7d13e7 100644 --- a/src/logger.zig +++ b/src/logger.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const Io = std.Io; pub fn query(comptime message: []const u8, args: anytype) void { printToStdout("[?] " ++ message, args); @@ -26,7 +27,7 @@ pub fn crit(comptime message: []const u8, args: anytype) void { fn printToStdout(comptime message: []const u8, args: anytype) void { var stdout_buf: [64]u8 = undefined; - var stdout_writer = std.fs.File.stdout().writer(&stdout_buf); + var stdout_writer = Io.File.stdout().writer(std.Options.debug_io, &stdout_buf); const stdout = &stdout_writer.interface; stdout.print(message, args) catch {}; @@ -35,7 +36,7 @@ fn printToStdout(comptime message: []const u8, args: anytype) void { fn printToStderr(comptime message: []const u8, args: anytype) void { var stderr_buf: [64]u8 = undefined; - var stderr_writer = std.fs.File.stderr().writer(&stderr_buf); + var stderr_writer = Io.File.stderr().writer(std.Options.debug_io, &stderr_buf); const stderr = &stderr_writer.interface; stderr.print(message, args) catch {}; diff --git a/src/maintenance.zig b/src/maintenance.zig index 3605f5b..062c16b 100644 --- a/src/maintenance.zig +++ b/src/maintenance.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const Io = std.Io; const logger = @import("logger.zig"); const metadata = @import("metadata.zig"); @@ -7,16 +8,16 @@ const wrapper = @import("wrapper.zig"); const MetaStruct = metadata.MetaStruct; -pub fn do_maint(args: [][:0]u8, install_dir: []const u8) !void { +pub fn do_maint(io: Io, args: []const []const u8, install_dir: []const u8) !void { var stdout_buf: [64]u8 = undefined; - var stdout_writer = std.fs.File.stdout().writer(&stdout_buf); + var stdout_writer = Io.File.stdout().writer(io, &stdout_buf); const stdout = &stdout_writer.interface; if (args.len < 1) { logger.warn("No sub-command provided!", .{}); } else { if (std.mem.eql(u8, args[0], "uninstall")) { - try do_uninstall(install_dir); + try do_uninstall(io, install_dir); } if (std.mem.eql(u8, args[0], "directory")) { @@ -31,7 +32,7 @@ pub fn do_maint(args: [][:0]u8, install_dir: []const u8) !void { fn confirm() !bool { var stdin_buf: [8]u8 = undefined; - var stdin_reader = std.fs.File.stdin().reader(&stdin_buf); + var stdin_reader = Io.File.stdin().reader(std.Options.debug_io, &stdin_buf); var stdin = &stdin_reader.interface; logger.query("Please confirm this action [y/n]: ", .{}); @@ -40,15 +41,15 @@ fn confirm() !bool { if (std.mem.eql(u8, user_input[0..1], "y") or std.mem.eql(u8, user_input[0..1], "Y")) { return true; } - } else |err| { - logger.err("Failed to confirm: {t}", .{err}); - return err; + } else |err_val| { + logger.err("Failed to confirm: {t}", .{err_val}); + return err_val; } return false; } -fn do_uninstall(install_dir: []const u8) !void { +fn do_uninstall(io: Io, install_dir: []const u8) !void { logger.warn("This will uninstall the application runtime for this Burrito binary!", .{}); if (try confirm() == false) { logger.warn("Uninstall was aborted!", .{}); @@ -57,51 +58,48 @@ fn do_uninstall(install_dir: []const u8) !void { } logger.info("Deleting directory: {s}", .{install_dir}); - try std.fs.deleteTreeAbsolute(install_dir); + try Io.Dir.cwd().deleteTree(io, install_dir); logger.info("Uninstall complete!", .{}); logger.info("Quitting.", .{}); } -fn print_metadata(out: *std.Io.Writer) !void { +fn print_metadata(out: *Io.Writer) !void { try out.print("{s}", .{wrapper.RELEASE_METADATA_JSON}); try out.flush(); } -fn print_install_dir(out: *std.Io.Writer, install_dir: []const u8) !void { +fn print_install_dir(out: *Io.Writer, install_dir: []const u8) !void { try out.print("{s}\n", .{install_dir}); try out.flush(); } -pub fn do_clean_old_versions(install_prefix_path: []const u8, current_install_path: []const u8) !void { +pub fn do_clean_old_versions(io: Io, install_prefix_path: []const u8, current_install_path: []const u8) !void { std.log.debug("Going to clean up older versions of this application...", .{}); var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); - const prefix_dir = try std.fs.openDirAbsolute(install_prefix_path, .{ .access_sub_paths = true, .iterate = true }); + const prefix_dir = try Io.Dir.openDirAbsolute(io, install_prefix_path, .{ .access_sub_paths = true, .iterate = true }); - const current_install = try install.load_install_from_path(allocator, current_install_path); + const current_install = try install.load_install_from_path(io, allocator, current_install_path); var itr = prefix_dir.iterate(); - while (try itr.next()) |dir| { + while (try itr.next(io)) |dir| { if (dir.kind == .directory) { const possible_app_path = try std.fs.path.join(allocator, &[_][]const u8{ install_prefix_path, dir.name }); - const other_install = try install.load_install_from_path(allocator, possible_app_path); + const other_install = try install.load_install_from_path(io, allocator, possible_app_path); - // If can can't figure out if this is an install dir, just ignore it if (other_install == null) { continue; } - // If this isn't the same installed app as us ignore it if (!std.mem.eql(u8, current_install.?.metadata.app_name, other_install.?.metadata.app_name)) { continue; } - // Compare the version, if it's older, delete the directory if (std.SemanticVersion.order(current_install.?.version, other_install.?.version) == .gt) { - try std.fs.deleteTreeAbsolute(other_install.?.install_dir_path); + try Io.Dir.cwd().deleteTree(io, other_install.?.install_dir_path); logger.log_stderr("Uninstalled older version (v{s})", .{other_install.?.metadata.app_version}); } } diff --git a/src/wrapper.zig b/src/wrapper.zig index 741aba2..48750be 100644 --- a/src/wrapper.zig +++ b/src/wrapper.zig @@ -1,26 +1,19 @@ -//// -// DO NOT EDIT THIS FILE -//// - const builtin = @import("builtin"); const launcher = @import("erlang_launcher.zig"); const build_options = @import("build_options"); const std = @import("std"); const json = std.json; const log = std.log; -const fs = std.fs; +const Io = std.Io; const Sha1 = std.crypto.hash.Sha1; const Base64 = std.base64.url_safe_no_pad.Encoder; -// Foilz Archive Util const foilz = @import("archiver.zig"); -// Maint utils const logger = @import("logger.zig"); const maint = @import("maintenance.zig"); -// Install dir suffix const install_suffix = ".burrito"; const plugin = @import("burrito_plugin"); @@ -30,40 +23,34 @@ const MetaStruct = metadata.MetaStruct; const IS_LINUX = builtin.os.tag == .linux; -// Payload pub const FOILZ_PAYLOAD = @embedFile("payload.foilz.xz"); pub const RELEASE_METADATA_JSON = @embedFile("_metadata.json"); -// Windows cmd argument parser const windows = std.os.windows; const LPCWSTR = windows.LPCWSTR; const LPWSTR = windows.LPWSTR; -pub fn main() !void { - var arena_impl = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer arena_impl.deinit(); +pub fn main(init: std.process.Init) !void { + const arena = init.arena.allocator(); + const io = init.io; - const arena = arena_impl.allocator(); + const args = try init.minimal.args.toSlice(arena); + const args_trimmed = args[1..]; - const args = try std.process.argsAlloc(arena); + const environ = init.minimal.environ; - // If on linux, maybe install the musl libc runtime file for our pre-compiled Erlang - try maybe_install_musl_runtime(arena); + try maybe_install_musl_runtime(io, arena); - // Trim args to only what we actually want to pass to erlang - const self_path = try std.fs.selfExePathAlloc(arena); - const args_trimmed = args[1..]; + const self_path = try std.process.executablePathAlloc(io, arena); - // If this is not a production build, we always want a clean install const wants_clean_install = !build_options.IS_PROD; const meta = metadata.parse(arena, RELEASE_METADATA_JSON).?; - const install_dir = try get_install_dir(arena, &meta); - const metadata_path = try fs.path.join(arena, &.{ install_dir, "_metadata.json" }); + const install_dir = try get_install_dir(io, environ, arena, &meta); + const metadata_path = try std.fs.path.join(arena, &.{ install_dir, "_metadata.json" }); - // Check for maintenance commands if (args_trimmed.len > 0 and std.mem.eql(u8, args_trimmed[0], "maintenance")) { - try maint.do_maint(args_trimmed[1..], install_dir); + try maint.do_maint(io, args_trimmed[1..], install_dir); return; } @@ -71,12 +58,10 @@ pub fn main() !void { log.debug("Install Directory: {s}", .{install_dir}); log.debug("Metadata path: {s}", .{metadata_path}); - // Ensure the destination directory is created - try std.fs.cwd().makePath(install_dir); + try Io.Dir.cwd().createDirPath(io, install_dir); - // If the metadata file exists, don't install again var needs_install: bool = false; - std.fs.accessAbsolute(metadata_path, .{}) catch |err| { + Io.Dir.cwd().access(io, metadata_path, .{}) catch |err| { if (err == error.FileNotFound) { needs_install = true; } else { @@ -87,33 +72,25 @@ pub fn main() !void { log.debug("Passing args string: {any}", .{args_trimmed}); - // Execute plugin code plugin.burrito_plugin_entry(install_dir, RELEASE_METADATA_JSON); - // If we need an install, install the payload onto the target machine if (needs_install or wants_clean_install) { - // If running a clean install (probably a debug build) - // delete existing install directory if it's present to prevent a MacOS SIP issue - // when "replacing" a mach-o in place if (wants_clean_install and !needs_install) { - try fs.deleteTreeAbsolute(install_dir); - try std.fs.cwd().makePath(install_dir); + try Io.Dir.cwd().deleteTree(io, install_dir); + try Io.Dir.cwd().createDirPath(io, install_dir); } - try do_payload_install(arena, install_dir, metadata_path); + try do_payload_install(io, arena, install_dir, metadata_path); } else { log.debug("Skipping archive unpacking, this machine already has the app installed!", .{}); } - // Clean up older versions - const base_install_path = try get_base_install_dir(arena); - try maint.do_clean_old_versions(base_install_path, install_dir); + const base_install_path = try get_base_install_dir(io, environ, arena); + try maint.do_clean_old_versions(io, base_install_path, install_dir); - // Get Env - var env_map = try std.process.getEnvMap(arena); + var env_map = try std.process.Environ.createMap(init.minimal.environ, arena); - // Add _IS_TTY env variable - if (std.fs.File.stdout().isTty()) { + if (try Io.File.stdout().isTty(io)) { try env_map.put("_IS_TTY", "1"); } else { try env_map.put("_IS_TTY", "0"); @@ -121,35 +98,31 @@ pub fn main() !void { log.debug("Launching erlang...", .{}); - try launcher.launch(install_dir, &env_map, &meta, self_path, args_trimmed); + try launcher.launch(io, install_dir, &env_map, &meta, self_path, args_trimmed); } -fn do_payload_install(arena: std.mem.Allocator, install_dir: []const u8, metadata_path: []const u8) !void { - // Unpack the files - try foilz.unpack_files(arena, FOILZ_PAYLOAD, install_dir, build_options.UNCOMPRESSED_SIZE); +fn do_payload_install(io: Io, arena: std.mem.Allocator, install_dir: []const u8, metadata_path: []const u8) !void { + try foilz.unpack_files(io, arena, FOILZ_PAYLOAD, install_dir, build_options.UNCOMPRESSED_SIZE); - // Write metadata file - const file = try fs.createFileAbsolute(metadata_path, .{ .truncate = true }); - try file.writeAll(RELEASE_METADATA_JSON); + const file = try Io.Dir.cwd().createFile(io, metadata_path, .{ .truncate = true }); + defer file.close(io); + try file.writePositionalAll(io, RELEASE_METADATA_JSON, 0); } -fn get_base_install_dir(arena: std.mem.Allocator) ![]const u8 { - // If we have a override for the install path, use that, otherwise, continue to return - // the standard install path +fn get_base_install_dir(_: Io, environ: std.process.Environ, arena: std.mem.Allocator) ![]const u8 { const upper_name = try std.ascii.allocUpperString(arena, build_options.RELEASE_NAME); const env_install_dir_name = try std.fmt.allocPrint(arena, "{s}_INSTALL_DIR", .{upper_name}); - if (std.process.getEnvVarOwned(arena, env_install_dir_name)) |new_path| { + var env_map = try std.process.Environ.createMap(environ, arena); + defer env_map.deinit(); + + if (env_map.get(env_install_dir_name)) |new_path| { logger.info("Install path is being overridden using `{s}`", .{env_install_dir_name}); logger.info("New install path is: {s}", .{new_path}); - return try fs.path.join(arena, &[_][]const u8{ new_path, install_suffix }); - } else |err| switch (err) { - error.InvalidWtf8 => {}, - error.EnvironmentVariableNotFound => {}, - error.OutOfMemory => {}, + return try std.fs.path.join(arena, &[_][]const u8{ new_path, install_suffix }); } - const app_dir = fs.getAppDataDir(arena, install_suffix) catch { + const app_dir = get_app_data_dir(arena, install_suffix) catch { install_dir_error(arena); return ""; }; @@ -157,25 +130,44 @@ fn get_base_install_dir(arena: std.mem.Allocator) ![]const u8 { return app_dir; } -fn get_install_dir(arena: std.mem.Allocator, meta: *const MetaStruct) ![]u8 { - // Combine the hash of the payload and a base dir to get a safe install directory - const base_install_path = try get_base_install_dir(arena); +fn get_app_data_dir(arena: std.mem.Allocator, appname: []const u8) ![]const u8 { + const getenv = struct { + fn get(name: [*:0]const u8) ?[]const u8 { + const val = std.c.getenv(name) orelse return null; + return std.mem.sliceTo(val, 0); + } + }.get; + + if (builtin.os.tag == .windows) { + const appdata = getenv("APPDATA") orelse return error.AppDataDirUnavailable; + return std.fs.path.join(arena, &.{ appdata, appname }); + } else if (builtin.os.tag == .macos or builtin.os.tag.isDarwin()) { + const home = getenv("HOME") orelse return error.AppDataDirUnavailable; + return std.fs.path.join(arena, &.{ home, "Library", "Application Support", appname }); + } else { + if (getenv("XDG_DATA_HOME")) |xdg| { + return std.fs.path.join(arena, &.{ xdg, appname }); + } + const home = getenv("HOME") orelse return error.AppDataDirUnavailable; + return std.fs.path.join(arena, &.{ home, ".local", "share", appname }); + } +} + +fn get_install_dir(io: Io, environ: std.process.Environ, arena: std.mem.Allocator, meta: *const MetaStruct) ![]u8 { + const base_install_path = try get_base_install_dir(io, environ, arena); - // Parse the ERTS version and app version from the metadata JSON string const dir_name = try std.fmt.allocPrint( arena, "{s}_erts-{s}_{s}", .{ build_options.RELEASE_NAME, meta.erts_version, meta.app_version }, ); - // Ensure that base directory is created - std.fs.cwd().makePath(base_install_path) catch { + Io.Dir.cwd().createDirPath(io, base_install_path) catch { install_dir_error(arena); return ""; }; - // Construct the full app install path - const name = fs.path.join(arena, &.{ base_install_path, dir_name }) catch { + const name = std.fs.path.join(arena, &.{ base_install_path, dir_name }) catch { install_dir_error(arena); return ""; }; @@ -199,33 +191,28 @@ fn install_dir_error(arena: std.mem.Allocator) void { std.process.exit(1); } -fn maybe_install_musl_runtime(arena: std.mem.Allocator) !void { +fn maybe_install_musl_runtime(io: Io, arena: std.mem.Allocator) !void { if (comptime IS_LINUX and !std.mem.eql(u8, build_options.MUSL_RUNTIME_PATH, "")) { - // Check if the file was already extracted const cStr = try arena.dupeZ(u8, build_options.MUSL_RUNTIME_PATH); var statBuffer: std.c.Stat = undefined; const statResult = std.c.stat(cStr, &statBuffer); if (statResult == 0) { - // File exists log.debug("The musl runtime file is already preset. Continuing.", .{}); return; } - const file = std.fs.createFileAbsolute( - build_options.MUSL_RUNTIME_PATH, - .{ .read = true }, - ) catch |e| { + const file = Io.Dir.cwd().createFile(io, build_options.MUSL_RUNTIME_PATH, .{ .read = true }) catch |e| { log.debug("Failed to extract burrito musl runtime: {}", .{e}); return; }; - defer file.close(); + defer file.close(io); - const exec_permissions = std.fs.File.PermissionsUnix.unixNew(0o754); - try file.setPermissions(.{ .inner = exec_permissions }); + const exec_permissions = Io.File.Permissions.unixNew(0o754); + try file.setPermissions(io, exec_permissions); const MUSL_RUNTIME_BYTES = @embedFile("musl-runtime.so"); - try file.writeAll(MUSL_RUNTIME_BYTES); + try file.writePositionalAll(io, MUSL_RUNTIME_BYTES, 0); log.debug("Wrote musl runtime file: {s}", .{build_options.MUSL_RUNTIME_PATH}); } From 17208c940f750c0d4467e7fb0ed686331e980aae Mon Sep 17 00:00:00 2001 From: Justin Smestad Date: Fri, 29 May 2026 13:14:15 -0600 Subject: [PATCH 2/7] Put comments back in --- build.zig | 2 ++ src/archiver.zig | 56 +++++++++++++++++++++++++++++++++++++++++ src/erlang_launcher.zig | 7 ++++++ src/wrapper.zig | 29 +++++++++++++++++++++ 4 files changed, 94 insertions(+) diff --git a/build.zig b/build.zig index 941ddda..e4a6890 100644 --- a/build.zig +++ b/build.zig @@ -7,6 +7,7 @@ const log = std.log; pub fn build(b: *std.Build) !void { log.info("Zig is building an Elixir binary... ⚡", .{}); + // Run build steps! try run_archiver(b); try build_wrapper(b); @@ -78,6 +79,7 @@ pub fn build_wrapper(b: *std.Build) !void { wrapper_exe.root_module.addIncludePath(b.path("src/")); } + // Link standard C libary to the wrapper wrapper_exe.root_module.linkSystemLibrary("c", .{}); if (plugin_path) |plugin| { diff --git a/src/archiver.zig b/src/archiver.zig index 7474e4d..59ea922 100644 --- a/src/archiver.zig +++ b/src/archiver.zig @@ -1,3 +1,35 @@ +///// +// This is a packing/unpacking utility used to pack up a elixir mix release into "FOILZ" archive. +// The structure of the FOILZ archive file is very simple, and akin to a very basic TAR archive: +// +// ┌────────────────────────┐ +// │ │ +// │ Magic Header: 'FOILZ' │ +// │ │ +// ├────────────────────────┤ +// ┌──────── │ u64 File Path Len │◄───────── Informs how long the string following will be +// │ ├────────────────────────┤ +// │ │ │ +// │ │ File Path Characters │◄───────── File path in release dir + file name +// File Record ────┤ │ │ +// │ ├────────────────────────┤ +// │ │ u64 File Byte Len │◄───────── Informs how long the file bytes following will be +// │ ├────────────────────────┤ +// │ │ │ +// │ │ File Bytes │◄───────── Raw bytes of file +// │ │ │ +// │ ├────────────────────────┤ +// └──────── │ usize File Mode │◄───────── POSIX File Mode (Ignored on Windows) +// ├────────────────────────┤ +// │ │ +// │ Magic Trailer: 'FOILZ' │ +// │ │ +// └────────────────────────┘ +// +// There can be many file records inside a FOILZ archive, after packing, it is gzip or xz compressed. +// At runtime, we decompress it in memory and write the files to disk in a common location. +///// + const builtin = @import("builtin"); const std = @import("std"); @@ -15,6 +47,7 @@ const MAX_READ_SIZE = 1000000000; pub fn pack_directory(arena: Allocator, path: []const u8, archive_path: []const u8) anyerror!void { const io = std.Options.debug_io; + // Open a file for the archive const arch_file = try Io.Dir.cwd().createFile(io, archive_path, .{ .truncate = true }); defer arch_file.close(io); @@ -34,6 +67,9 @@ pub fn pack_directory(arena: Allocator, path: []const u8, archive_path: []const while (try walker.next(io)) |entry| { if (entry.kind == .file) { + // Replace some path string data for the tar index name + // specifically replace: '../_build/prod/rel/' --> '' + // This just makes it easier to write the files out later on the destination machine const needle = path; const replacement = ""; const replacement_size = mem.replacementSize(u8, entry.path, needle, replacement); @@ -50,6 +86,7 @@ pub fn pack_directory(arena: Allocator, path: []const u8, archive_path: []const const stat = try file.stat(io); + // Write file record to archive const name = index; try writer.writeInt(u64, name.len, .little); try writer.writeAll(name); @@ -73,6 +110,7 @@ pub fn pack_directory(arena: Allocator, path: []const u8, archive_path: []const } pub fn unpack_files(io: Io, arena: Allocator, data: []const u8, dest_path: []const u8, uncompressed_size: u64) !void { + // Decompress the data in the payload var decompressed: []u8 = try arena.alloc(u8, uncompressed_size); var xz_buffer: xz.xz_buf = .{ @@ -94,36 +132,53 @@ pub fn unpack_files(io: Io, arena: Allocator, data: []const u8, dest_path: []con return error.ParseError; } + // Validate the header of the payload if (!std.mem.eql(u8, MAGIC, decompressed[0..5])) { return error.BadHeader; } + // We start at position 5 to skip the header var cursor: u64 = 5; var file_count: u64 = 0; + ////// + // Read until we reach the end of the trailer + // Look ahead 5 bytes and see while (cursor < decompressed.len - 5) { + ////// + // Read the file name const string_len = std.mem.readInt(u64, decompressed[cursor .. cursor + @sizeOf(u64)][0..8], .little); cursor = cursor + @sizeOf(u64); const file_name = decompressed[cursor .. cursor + string_len]; cursor = cursor + string_len; + ////// + // Read the file data from the payload const file_len = std.mem.readInt(u64, decompressed[cursor .. cursor + @sizeOf(u64)][0..8], .little); cursor = cursor + @sizeOf(u64); const file_data = decompressed[cursor .. cursor + file_len]; cursor = cursor + file_len; + ////// + // Read the mode for this file const file_mode = std.mem.readInt(usize, decompressed[cursor .. cursor + @sizeOf(usize)][0..@sizeOf(usize)], .little); cursor = cursor + @sizeOf(usize); + ////// + // Write the file const full_file_path = try std.fs.path.join(arena, &[_][]const u8{ dest_path[0..], file_name }); + ////// + // Create any directories needed const dir_name = std.fs.path.dirname(file_name); if (dir_name != null) try create_dirs(io, dest_path[0..], dir_name.?, arena); log.debug("Unpacked File: {s}", .{full_file_path}); + ////// + // Write the file to disk! { const file = try Io.Dir.cwd().createFile(io, full_file_path, .{ .truncate = true }); if (file_len > 0) { @@ -160,6 +215,7 @@ fn create_dirs(io: Io, dest_path: []const u8, sub_dir_names: []const u8, allocat } } +// Adapted from `std.log`, but without forcing a newline fn direct_log(comptime message: []const u8, args: anytype) void { var buf: [64]u8 = undefined; var w = Io.File.stderr().writer(std.Options.debug_io, &buf); diff --git a/src/erlang_launcher.zig b/src/erlang_launcher.zig index feea163..6e2cdbc 100644 --- a/src/erlang_launcher.zig +++ b/src/erlang_launcher.zig @@ -21,6 +21,7 @@ pub fn launch(io: Io, install_dir: []const u8, env_map: *std.process.Environ.Map var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); const allocator = arena.allocator(); + // Computer directories we care about const release_cookie_path = try std.fs.path.join(allocator, &[_][]const u8{ install_dir, "releases", "COOKIE" }); const release_lib_path = try std.fs.path.join(allocator, &[_][]const u8{ install_dir, "lib" }); const install_vm_args_path = try std.fs.path.join(allocator, &[_][]const u8{ install_dir, "releases", meta.app_version, "vm.args" }); @@ -33,16 +34,19 @@ pub fn launch(io: Io, install_dir: []const u8, env_map: *std.process.Environ.Map const erts_bin_path = try std.fs.path.join(allocator, &[_][]const u8{ install_dir, erts_version_name, "bin" }); const erl_bin_path = try std.fs.path.join(allocator, &[_][]const u8{ erts_bin_path, get_erl_exe_name() }); + // Read the Erlang COOKIE file for the release const release_cookie_file = try Io.Dir.openFileAbsolute(io, release_cookie_path, .{ .mode = .read_write }); defer release_cookie_file.close(io); var read_buf: [1024]u8 = undefined; var cookie_reader = release_cookie_file.reader(io, &read_buf); var release_cookie_content: []const u8 = try cookie_reader.interface.allocRemaining(allocator, @enumFromInt(MAX_READ_SIZE)); + // Override the cookie if the env variable RELEASE_COOKIE is defined if (env_map.get("RELEASE_COOKIE")) |cookie| { release_cookie_content = cookie; } + // Set all the required release arguments const erlang_cli = &[_][]const u8{ erl_bin_path[0..], "-elixir ansi_enabled true", @@ -64,6 +68,7 @@ pub fn launch(io: Io, install_dir: []const u8, env_map: *std.process.Environ.Map }; if (builtin.os.tag == .windows) { + // Fix up Windows 10+ consoles having ANSI escape support, but only if we set some flags const final_args = try std.mem.concat(allocator, []const u8, &.{ erlang_cli, args_trimmed }); try env_map.put("RELEASE_ROOT", install_dir); @@ -97,6 +102,8 @@ pub fn launch(io: Io, install_dir: []const u8, env_map: *std.process.Environ.Map try env_map.put("__BURRITO", "1"); try env_map.put("__BURRITO_BIN_PATH", self_path); + // Extend LD_LIBRARY_PATH so NIF .so files can find system shared + // libraries (e.g. libgcc_s.so.1) when using a custom glibc ERTS const system_lib_paths = "/lib/x86_64-linux-gnu:/usr/lib/x86_64-linux-gnu:/lib:/usr/lib"; if (env_map.get("LD_LIBRARY_PATH")) |existing| { const combined = try std.fmt.allocPrint(allocator, "{s}:{s}", .{ existing, system_lib_paths }); diff --git a/src/wrapper.zig b/src/wrapper.zig index 48750be..8763e68 100644 --- a/src/wrapper.zig +++ b/src/wrapper.zig @@ -9,11 +9,14 @@ const Io = std.Io; const Sha1 = std.crypto.hash.Sha1; const Base64 = std.base64.url_safe_no_pad.Encoder; +// Foilz Archive Util const foilz = @import("archiver.zig"); +// Maint utils const logger = @import("logger.zig"); const maint = @import("maintenance.zig"); +// Install dir suffix const install_suffix = ".burrito"; const plugin = @import("burrito_plugin"); @@ -23,9 +26,11 @@ const MetaStruct = metadata.MetaStruct; const IS_LINUX = builtin.os.tag == .linux; +// Payload pub const FOILZ_PAYLOAD = @embedFile("payload.foilz.xz"); pub const RELEASE_METADATA_JSON = @embedFile("_metadata.json"); +// Windows cmd argument parser const windows = std.os.windows; const LPCWSTR = windows.LPCWSTR; const LPWSTR = windows.LPWSTR; @@ -35,20 +40,24 @@ pub fn main(init: std.process.Init) !void { const io = init.io; const args = try init.minimal.args.toSlice(arena); + // Trim args to only what we actually want to pass to erlang const args_trimmed = args[1..]; const environ = init.minimal.environ; + // If on linux, maybe install the musl libc runtime file for our pre-compiled Erlang try maybe_install_musl_runtime(io, arena); const self_path = try std.process.executablePathAlloc(io, arena); + // If this is not a production build, we always want a clean install const wants_clean_install = !build_options.IS_PROD; const meta = metadata.parse(arena, RELEASE_METADATA_JSON).?; const install_dir = try get_install_dir(io, environ, arena, &meta); const metadata_path = try std.fs.path.join(arena, &.{ install_dir, "_metadata.json" }); + // Check for maintenance commands if (args_trimmed.len > 0 and std.mem.eql(u8, args_trimmed[0], "maintenance")) { try maint.do_maint(io, args_trimmed[1..], install_dir); return; @@ -58,8 +67,10 @@ pub fn main(init: std.process.Init) !void { log.debug("Install Directory: {s}", .{install_dir}); log.debug("Metadata path: {s}", .{metadata_path}); + // Ensure the destination directory is created try Io.Dir.cwd().createDirPath(io, install_dir); + // If the metadata file exists, don't install again var needs_install: bool = false; Io.Dir.cwd().access(io, metadata_path, .{}) catch |err| { if (err == error.FileNotFound) { @@ -72,9 +83,14 @@ pub fn main(init: std.process.Init) !void { log.debug("Passing args string: {any}", .{args_trimmed}); + // Execute plugin code plugin.burrito_plugin_entry(install_dir, RELEASE_METADATA_JSON); + // If we need an install, install the payload onto the target machine if (needs_install or wants_clean_install) { + // If running a clean install (probably a debug build) + // delete existing install directory if it's present to prevent a MacOS SIP issue + // when "replacing" a mach-o in place if (wants_clean_install and !needs_install) { try Io.Dir.cwd().deleteTree(io, install_dir); try Io.Dir.cwd().createDirPath(io, install_dir); @@ -85,11 +101,14 @@ pub fn main(init: std.process.Init) !void { log.debug("Skipping archive unpacking, this machine already has the app installed!", .{}); } + // Clean up older versions const base_install_path = try get_base_install_dir(io, environ, arena); try maint.do_clean_old_versions(io, base_install_path, install_dir); + // Get Env var env_map = try std.process.Environ.createMap(init.minimal.environ, arena); + // Add _IS_TTY env variable if (try Io.File.stdout().isTty(io)) { try env_map.put("_IS_TTY", "1"); } else { @@ -102,13 +121,17 @@ pub fn main(init: std.process.Init) !void { } fn do_payload_install(io: Io, arena: std.mem.Allocator, install_dir: []const u8, metadata_path: []const u8) !void { + // Unpack the files try foilz.unpack_files(io, arena, FOILZ_PAYLOAD, install_dir, build_options.UNCOMPRESSED_SIZE); + // Write metadata file const file = try Io.Dir.cwd().createFile(io, metadata_path, .{ .truncate = true }); defer file.close(io); try file.writePositionalAll(io, RELEASE_METADATA_JSON, 0); } +// If we have a override for the install path, use that, otherwise, continue to return +// the standard install path fn get_base_install_dir(_: Io, environ: std.process.Environ, arena: std.mem.Allocator) ![]const u8 { const upper_name = try std.ascii.allocUpperString(arena, build_options.RELEASE_NAME); const env_install_dir_name = try std.fmt.allocPrint(arena, "{s}_INSTALL_DIR", .{upper_name}); @@ -154,19 +177,23 @@ fn get_app_data_dir(arena: std.mem.Allocator, appname: []const u8) ![]const u8 { } fn get_install_dir(io: Io, environ: std.process.Environ, arena: std.mem.Allocator, meta: *const MetaStruct) ![]u8 { + // Combine the hash of the payload and a base dir to get a safe install directory const base_install_path = try get_base_install_dir(io, environ, arena); + // Parse the ERTS version and app version from the metadata JSON string const dir_name = try std.fmt.allocPrint( arena, "{s}_erts-{s}_{s}", .{ build_options.RELEASE_NAME, meta.erts_version, meta.app_version }, ); + // Ensure that base directory is created Io.Dir.cwd().createDirPath(io, base_install_path) catch { install_dir_error(arena); return ""; }; + // Construct the full app install path const name = std.fs.path.join(arena, &.{ base_install_path, dir_name }) catch { install_dir_error(arena); return ""; @@ -193,11 +220,13 @@ fn install_dir_error(arena: std.mem.Allocator) void { fn maybe_install_musl_runtime(io: Io, arena: std.mem.Allocator) !void { if (comptime IS_LINUX and !std.mem.eql(u8, build_options.MUSL_RUNTIME_PATH, "")) { + // Check if the file was already extracted const cStr = try arena.dupeZ(u8, build_options.MUSL_RUNTIME_PATH); var statBuffer: std.c.Stat = undefined; const statResult = std.c.stat(cStr, &statBuffer); if (statResult == 0) { + // File exists log.debug("The musl runtime file is already preset. Continuing.", .{}); return; } From de9c866105e4681c2ce8e0e1dfb8b2111f3f8e90 Mon Sep 17 00:00:00 2001 From: Brezn Date: Fri, 29 May 2026 13:44:26 -0600 Subject: [PATCH 3/7] Update tool-versions --- .tool-versions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tool-versions b/.tool-versions index 74ff0c6..bf42797 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -zig 0.15.2 +zig 0.16.0 erlang 28.0.2 elixir 1.18.4-otp-28 From e18b81dcd15ca50ee1c49b8e6438cc04d8b6d7a0 Mon Sep 17 00:00:00 2001 From: Gilbert Date: Fri, 12 Jun 2026 08:55:27 +0800 Subject: [PATCH 4/7] feat: Zig 0.16.0 compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-compilation fixes (Zig 0.16.0 API changes): - Replace std.c.stat with Io.Dir.cwd().statFile - Rename Permissions.unixNew → Permissions.fromMode - Remove unused arena parameter from maybe_install_musl_runtime - Comptime-guard the musl runtime call site on macOS - Guard Permissions.toMode() on Windows (enum, no POSIX mode bits) Process spawning (cross-platform): - Replace std.process.replace with std.process.spawn + child.wait (std.process.replace does not work reliably on macOS arm64) - Unify Windows/Unix code paths via the same spawn+wait pattern CI: - Add Zig 0.16.0 to the cross-build matrix - Add macOS arm64 (macos-14) runner --- .github/workflows/burrito-xcomp-check.yaml | 48 ++++++++++++----- src/archiver.zig | 11 +++- src/erlang_launcher.zig | 60 +++++++++------------- src/wrapper.zig | 18 +++---- 4 files changed, 77 insertions(+), 60 deletions(-) diff --git a/.github/workflows/burrito-xcomp-check.yaml b/.github/workflows/burrito-xcomp-check.yaml index 99316af..9bf4748 100644 --- a/.github/workflows/burrito-xcomp-check.yaml +++ b/.github/workflows/burrito-xcomp-check.yaml @@ -1,32 +1,47 @@ name: "Burrito Cross Build Build Tests" + on: pull_request: push: branches: [main] + jobs: build_examples: - name: build_examples_${{ matrix.host }} + name: build_examples_${{ matrix.host }}_${{ matrix.zig }} runs-on: ${{ matrix.host }} strategy: + fail-fast: false matrix: host: [ubuntu-latest, macos-latest] + zig: ["0.15.2", "0.16.0"] steps: # Deps - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 if: matrix.host == 'ubuntu-latest' with: otp-version: "27.3" elixir-version: "1.18.3" + + - uses: erlef/setup-beam@v1 + if: matrix.host == 'macos-latest' + with: + otp-version: "27.3" + elixir-version: "1.18.3" + - uses: goto-bus-stop/setup-zig@v2 with: - version: 0.15.2 + version: ${{ matrix.zig }} + - name: Set up Homebrew if: matrix.host == 'macos-latest' id: set-up-homebrew uses: Homebrew/actions/setup-homebrew@master + - run: sudo apt-get -y install xz-utils if: matrix.host == 'ubuntu-latest' + - run: brew install elixir xz if: matrix.host == 'macos-latest' @@ -36,13 +51,13 @@ jobs: id: cache-restore-linux with: path: /home/runner/.cache/burrito_file_cache/ - key: burrito-download-cache_${{ matrix.host }} + key: burrito-download-cache_${{ matrix.host }}_${{ matrix.zig }} - uses: actions/cache/restore@v3 if: matrix.host == 'macos-latest' id: cache-restore-macos with: path: /Users/runner/Library/Caches/burrito_file_cache/ - key: burrito-download-cache_${{ matrix.host }} + key: burrito-download-cache_${{ matrix.host }}_${{ matrix.zig }} # Build Example CLI App - name: cli_example @@ -55,7 +70,7 @@ jobs: id: cache-save-linux with: path: /home/runner/.cache/burrito_file_cache/ - key: burrito-download-cache_${{ matrix.host }} + key: burrito-download-cache_${{ matrix.host }}_${{ matrix.zig }} # Save Cache (macOS) - uses: actions/cache/save@v3 @@ -63,54 +78,61 @@ jobs: id: cache-save-macos with: path: /Users/runner/Library/Caches/burrito_file_cache/ - key: burrito-download-cache_${{ matrix.host }} + key: burrito-download-cache_${{ matrix.host }}_${{ matrix.zig }} # Upload Wrapped Binaries - name: Upload Binaries uses: actions/upload-artifact@v4 with: - name: burrito_${{ matrix.host }}_host_bins + name: burrito_${{ matrix.host }}_${{ matrix.zig }}_host_bins path: ./examples/**/burrito_out/* retention-days: 1 #### Run example binaries #### + # Windows binaries: built on Linux/macOS hosts, run on windows-latest run_examples_windows: + needs: build_examples strategy: + fail-fast: false matrix: host: [ubuntu-latest, macos-latest] + zig: ["0.15.2", "0.16.0"] runs-on: windows-latest - needs: build_examples steps: - name: Download a single artifact uses: actions/download-artifact@v4 with: - name: burrito_${{ matrix.host }}_host_bins + name: burrito_${{ matrix.host }}_${{ matrix.zig }}_host_bins - run: cli_example/burrito_out/example_cli_app_windows.exe run_examples_linux: + needs: build_examples strategy: + fail-fast: false matrix: host: [ubuntu-latest, macos-latest] + zig: ["0.15.2", "0.16.0"] runs-on: ubuntu-latest - needs: build_examples steps: - name: Download a single artifact uses: actions/download-artifact@v4 with: - name: burrito_${{ matrix.host }}_host_bins + name: burrito_${{ matrix.host }}_${{ matrix.zig }}_host_bins - run: chmod +x cli_example/burrito_out/example_cli_app_linux - run: cli_example/burrito_out/example_cli_app_linux run_examples_macos: + needs: build_examples strategy: + fail-fast: false matrix: host: [ubuntu-latest, macos-latest] + zig: ["0.15.2", "0.16.0"] runs-on: macos-latest - needs: build_examples steps: - name: Download a single artifact uses: actions/download-artifact@v4 with: - name: burrito_${{ matrix.host }}_host_bins + name: burrito_${{ matrix.host }}_${{ matrix.zig }}_host_bins - run: chmod +x cli_example/burrito_out/example_cli_app_macos - run: cli_example/burrito_out/example_cli_app_macos diff --git a/src/archiver.zig b/src/archiver.zig index 59ea922..5c0a526 100644 --- a/src/archiver.zig +++ b/src/archiver.zig @@ -94,7 +94,16 @@ pub fn pack_directory(arena: Allocator, path: []const u8, archive_path: []const if (stat.size > 0) { assert(stat.size == try reader.streamRemaining(writer)); } - try writer.writeInt(usize, @intCast(stat.permissions.toMode()), .little); + // On Windows, std.fs.File.Permissions is an enum (no + // .toMode() method), and POSIX mode bits don't apply. + // Write 0 — the archive is read on the same machine that + // wrote it, so the receiver can derive permissions from + // its own filesystem. + const mode: usize = if (builtin.os.tag == .windows) + 0 + else + @intCast(stat.permissions.toMode()); + try writer.writeInt(usize, mode, .little); count += 1; diff --git a/src/erlang_launcher.zig b/src/erlang_launcher.zig index 6e2cdbc..c144ebe 100644 --- a/src/erlang_launcher.zig +++ b/src/erlang_launcher.zig @@ -46,7 +46,10 @@ pub fn launch(io: Io, install_dir: []const u8, env_map: *std.process.Environ.Map release_cookie_content = cookie; } - // Set all the required release arguments + // Set all the required release arguments. CLI args are passed + // through native argv (after `-extra` below) and reach the BEAM + // via :init.get_plain_arguments/0. + const erlang_cli = &[_][]const u8{ erl_bin_path[0..], "-elixir ansi_enabled true", @@ -67,43 +70,23 @@ pub fn launch(io: Io, install_dir: []const u8, env_map: *std.process.Environ.Map "-extra", }; - if (builtin.os.tag == .windows) { - // Fix up Windows 10+ consoles having ANSI escape support, but only if we set some flags - const final_args = try std.mem.concat(allocator, []const u8, &.{ erlang_cli, args_trimmed }); - - try env_map.put("RELEASE_ROOT", install_dir); - try env_map.put("RELEASE_SYS_CONFIG", config_sys_path_no_ext); - try env_map.put("__BURRITO", "1"); - try env_map.put("__BURRITO_BIN_PATH", self_path); - - var win_child_proc = std.process.Child.init(final_args, allocator); - win_child_proc.env_map = env_map; - win_child_proc.stdout_behavior = .Inherit; - win_child_proc.stdin_behavior = .Inherit; - - log.debug("CLI List: {any}", .{final_args}); - - const win_term = try win_child_proc.spawnAndWait(); - switch (win_term) { - .Exited => |code| { - std.process.exit(code); - }, - else => std.process.exit(1), - } - } else { - const final_args = try std.mem.concat(allocator, []const u8, &.{ erlang_cli, args_trimmed }); + // Cross-platform: build args once, set env, spawn child, wait for exit + const final_args = try std.mem.concat(allocator, []const u8, &.{ erlang_cli, args_trimmed }); + + log.debug("CLI List: {any}", .{final_args}); - log.debug("CLI List: {any}", .{final_args}); + try env_map.put("RELEASE_ROOT", install_dir); + try env_map.put("RELEASE_SYS_CONFIG", config_sys_path_no_ext); + try env_map.put("__BURRITO", "1"); + try env_map.put("__BURRITO_BIN_PATH", self_path); + // Unix: set ROOTDIR, BINDIR, LD_LIBRARY_PATH for NIF .so files + if (builtin.os.tag != .windows) { try env_map.put("ROOTDIR", install_dir[0..]); try env_map.put("BINDIR", erts_bin_path[0..]); - try env_map.put("RELEASE_ROOT", install_dir); - try env_map.put("RELEASE_SYS_CONFIG", config_sys_path_no_ext); - try env_map.put("__BURRITO", "1"); - try env_map.put("__BURRITO_BIN_PATH", self_path); // Extend LD_LIBRARY_PATH so NIF .so files can find system shared - // libraries (e.g. libgcc_s.so.1) when using a custom glibc ERTS + // libraries (e.g. libgcc_s.so.1) when using a custom ERTS const system_lib_paths = "/lib/x86_64-linux-gnu:/usr/lib/x86_64-linux-gnu:/lib:/usr/lib"; if (env_map.get("LD_LIBRARY_PATH")) |existing| { const combined = try std.fmt.allocPrint(allocator, "{s}:{s}", .{ existing, system_lib_paths }); @@ -111,10 +94,15 @@ pub fn launch(io: Io, install_dir: []const u8, env_map: *std.process.Environ.Map } else { try env_map.put("LD_LIBRARY_PATH", system_lib_paths); } + } - return std.process.replace(io, .{ - .argv = final_args, - .environ_map = env_map, - }); + var child = try std.process.spawn(io, .{ + .argv = final_args, + .environ_map = env_map, + }); + const term = try child.wait(io); + switch (term) { + .exited => |code| std.process.exit(code), + else => std.process.exit(1), } } diff --git a/src/wrapper.zig b/src/wrapper.zig index 8763e68..0b2255d 100644 --- a/src/wrapper.zig +++ b/src/wrapper.zig @@ -46,7 +46,7 @@ pub fn main(init: std.process.Init) !void { const environ = init.minimal.environ; // If on linux, maybe install the musl libc runtime file for our pre-compiled Erlang - try maybe_install_musl_runtime(io, arena); + if (comptime IS_LINUX) try maybe_install_musl_runtime(io); const self_path = try std.process.executablePathAlloc(io, arena); @@ -218,16 +218,14 @@ fn install_dir_error(arena: std.mem.Allocator) void { std.process.exit(1); } -fn maybe_install_musl_runtime(io: Io, arena: std.mem.Allocator) !void { - if (comptime IS_LINUX and !std.mem.eql(u8, build_options.MUSL_RUNTIME_PATH, "")) { - // Check if the file was already extracted - const cStr = try arena.dupeZ(u8, build_options.MUSL_RUNTIME_PATH); - var statBuffer: std.c.Stat = undefined; - const statResult = std.c.stat(cStr, &statBuffer); +fn maybe_install_musl_runtime(io: Io) !void { + if (!std.mem.eql(u8, build_options.MUSL_RUNTIME_PATH, "")) { + // Check if the file was already extracted using std.fs API (cross-platform) + const file_exists = Io.Dir.cwd().statFile(io, build_options.MUSL_RUNTIME_PATH, .{}) catch null; - if (statResult == 0) { + if (file_exists != null) { // File exists - log.debug("The musl runtime file is already preset. Continuing.", .{}); + log.debug("The musl runtime file is already present. Continuing.", .{}); return; } @@ -237,7 +235,7 @@ fn maybe_install_musl_runtime(io: Io, arena: std.mem.Allocator) !void { }; defer file.close(io); - const exec_permissions = Io.File.Permissions.unixNew(0o754); + const exec_permissions = Io.File.Permissions.fromMode(@intCast(0o754)); try file.setPermissions(io, exec_permissions); const MUSL_RUNTIME_BYTES = @embedFile("musl-runtime.so"); From 0f9761b0b5c381b82f0d5e8b3c75400ceec0e397 Mon Sep 17 00:00:00 2001 From: Gilbert Date: Sat, 27 Jun 2026 09:57:56 +0800 Subject: [PATCH 5/7] fix: split erlang CLI args to prevent BEAM hang with spawn+wait Passing '-elixir ansi_enabled true' and '-s elixir start_cli' as single argv entries (with embedded spaces) caused the BEAM to hang in __select when spawned via std.process.spawn on macOS. erlexec passes them through to beam.smp as-is, and beam.smp doesn't split space-delimited args. Splitting them into separate argv entries fixes the hang. --- src/erlang_launcher.zig | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/erlang_launcher.zig b/src/erlang_launcher.zig index c144ebe..6367ebf 100644 --- a/src/erlang_launcher.zig +++ b/src/erlang_launcher.zig @@ -52,9 +52,13 @@ pub fn launch(io: Io, install_dir: []const u8, env_map: *std.process.Environ.Map const erlang_cli = &[_][]const u8{ erl_bin_path[0..], - "-elixir ansi_enabled true", + "-elixir", + "ansi_enabled", + "true", "-noshell", - "-s elixir start_cli", + "-s", + "elixir", + "start_cli", "-mode embedded", "-setcookie", release_cookie_content, @@ -96,6 +100,8 @@ pub fn launch(io: Io, install_dir: []const u8, env_map: *std.process.Environ.Map } } + // Spawn child and wait for exit. + // The BEAM's exit code becomes the Burrito binary's exit code. var child = try std.process.spawn(io, .{ .argv = final_args, .environ_map = env_map, From 605768f2d09e2943fb1bc484c0cc44400dde50de Mon Sep 17 00:00:00 2001 From: Gilbert Date: Sun, 5 Jul 2026 14:16:07 +0800 Subject: [PATCH 6/7] fix: CI Elixir shadowing and drop Zig 0.15.2 matrix On macOS, `brew install elixir xz` installed Elixir 1.20.2 (OTP 29) which shadowed setup-beam's Elixir 1.18.3 (OTP 27), causing a Hex ABI mismatch (`beam_load.c` errors) at `mix deps.get`. Only `xz` is needed via brew since setup-beam already provides Elixir. Drop the 0.15.2 Zig matrix entries since lib/burrito.ex hardcodes `@zig_version_expected` to 0.16.0 with a strict equality check, making 0.15.2 builds fail the version guard. --- .github/workflows/burrito-xcomp-check.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/burrito-xcomp-check.yaml b/.github/workflows/burrito-xcomp-check.yaml index 9bf4748..c89eed8 100644 --- a/.github/workflows/burrito-xcomp-check.yaml +++ b/.github/workflows/burrito-xcomp-check.yaml @@ -13,7 +13,7 @@ jobs: fail-fast: false matrix: host: [ubuntu-latest, macos-latest] - zig: ["0.15.2", "0.16.0"] + zig: ["0.16.0"] steps: # Deps - uses: actions/checkout@v4 @@ -42,7 +42,7 @@ jobs: - run: sudo apt-get -y install xz-utils if: matrix.host == 'ubuntu-latest' - - run: brew install elixir xz + - run: brew install xz if: matrix.host == 'macos-latest' # Restore Cache @@ -96,7 +96,7 @@ jobs: fail-fast: false matrix: host: [ubuntu-latest, macos-latest] - zig: ["0.15.2", "0.16.0"] + zig: ["0.16.0"] runs-on: windows-latest steps: - name: Download a single artifact @@ -111,7 +111,7 @@ jobs: fail-fast: false matrix: host: [ubuntu-latest, macos-latest] - zig: ["0.15.2", "0.16.0"] + zig: ["0.16.0"] runs-on: ubuntu-latest steps: - name: Download a single artifact @@ -127,7 +127,7 @@ jobs: fail-fast: false matrix: host: [ubuntu-latest, macos-latest] - zig: ["0.15.2", "0.16.0"] + zig: ["0.16.0"] runs-on: macos-latest steps: - name: Download a single artifact From 0a246a22afa6b3680c8ea11283567dac3ac1c1b4 Mon Sep 17 00:00:00 2001 From: Gilbert Date: Wed, 22 Jul 2026 10:02:33 +0800 Subject: [PATCH 7/7] fix: pipe child stdout through wrapper to handle EPIPE When stdout is piped to a command that exits early (e.g. `app cmd | head -5`), the BEAM's standard_io group leader crashes on EPIPE and the VM hangs indefinitely trying to flush IO during shutdown. The wrapper now pipes child stdout through itself via a copy thread. When the downstream pipe breaks (EPIPE on write), the copy thread kills the child process, allowing the wrapper to exit cleanly instead of blocking on child.wait() forever. --- src/erlang_launcher.zig | 61 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/src/erlang_launcher.zig b/src/erlang_launcher.zig index 6367ebf..ddad0fa 100644 --- a/src/erlang_launcher.zig +++ b/src/erlang_launcher.zig @@ -100,15 +100,62 @@ pub fn launch(io: Io, install_dir: []const u8, env_map: *std.process.Environ.Map } } - // Spawn child and wait for exit. - // The BEAM's exit code becomes the Burrito binary's exit code. - var child = try std.process.spawn(io, .{ - .argv = final_args, - .environ_map = env_map, - }); - const term = try child.wait(io); + // On Unix: pipe child stdout through us so we can detect EPIPE from + // the downstream consumer (e.g. `app cmd | head -5`). When the consumer + // exits and breaks the pipe, the copy thread kills the BEAM child. + // On Windows: inherit stdout directly — std.c.read blocks on Windows + // pipes, and the EPIPE group-leader hang is Unix-specific anyway. + var child: std.process.Child = undefined; + var copy_thread: ?std.Thread = null; + + if (builtin.os.tag != .windows) { + child = try std.process.spawn(io, .{ + .argv = final_args, + .environ_map = env_map, + .stdout = .pipe, + }); + copy_thread = try std.Thread.spawn(.{}, stdoutCopyThread, .{io, &child}); + } else { + child = try std.process.spawn(io, .{ + .argv = final_args, + .environ_map = env_map, + }); + } + + const term = if (builtin.os.tag != .windows) + child.wait(io) catch { + copy_thread.?.join(); + std.process.exit(0); + } + else + try child.wait(io); + + if (copy_thread) |t| t.join(); + switch (term) { .exited => |code| std.process.exit(code), else => std.process.exit(1), } } + +/// Copies child process stdout to our stdout (Unix only). +/// When the downstream pipe breaks (EPIPE), kills the child to prevent +/// it from hanging during VM shutdown (BEAM tries to flush standard_io +/// which blocks forever on a dead pipe). +fn stdoutCopyThread(io: Io, child: *std.process.Child) void { + if (builtin.os.tag == .windows) return; + const stdout_file = child.stdout orelse return; + const stdin_fd = stdout_file.handle; + const stdout_fd = Io.File.stdout().handle; + var buf: [16384]u8 = undefined; + + while (true) { + const n = std.posix.read(stdin_fd, &buf) catch break; + if (n == 0) break; + const written = std.c.write(stdout_fd, buf[0..n].ptr, n); + if (written < 0) { + child.kill(io); + return; + } + } +}