diff --git a/.github/workflows/burrito-xcomp-check.yaml b/.github/workflows/burrito-xcomp-check.yaml index 99316af..c89eed8 100644 --- a/.github/workflows/burrito-xcomp-check.yaml +++ b/.github/workflows/burrito-xcomp-check.yaml @@ -1,33 +1,48 @@ 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.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 + + - run: brew install xz if: matrix.host == 'macos-latest' # Restore Cache @@ -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.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.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.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/.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 diff --git a/build.zig b/build.zig index 03e8125..e4a6890 100644 --- a/build.zig +++ b/build.zig @@ -1,17 +1,9 @@ -//// -// 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... ⚡", .{}); @@ -25,7 +17,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 +39,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 +76,11 @@ 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 +95,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..5c0a526 100644 --- a/src/archiver.zig +++ b/src/archiver.zig @@ -35,11 +35,9 @@ 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 +45,18 @@ const MAGIC = "FOILZ"; 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 fs.cwd().createFile(archive_path, .{ .truncate = true }); - defer arch_file.close(); + 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,7 +65,7 @@ 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/' --> '' @@ -73,18 +73,18 @@ pub fn pack_directory(arena: Allocator, path: []const u8, archive_path: []const 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; @@ -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, stat.mode, .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; @@ -103,17 +112,14 @@ 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 { +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 = .{ @@ -171,31 +177,26 @@ pub fn unpack_files(arena: Allocator, data: []const u8, dest_path: []const u8, u ////// // 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 +205,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}); @@ -225,11 +226,9 @@ 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..ddad0fa 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,44 +17,48 @@ 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 + // 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", + "-elixir", + "ansi_enabled", + "true", "-noshell", - "-s elixir start_cli", + "-s", + "elixir", + "start_cli", "-mode embedded", "-setcookie", release_cookie_content, @@ -72,62 +74,88 @@ pub fn launch(install_dir: []const u8, env_map: *EnvMap, meta: *const MetaStruct "-extra", }; - 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); - 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}); - var erl_env_map = EnvMap.init(allocator); - defer erl_env_map.deinit(); + 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 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); + // 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..]); // 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 (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); + } + } + + // 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(); - return std.process.execve(allocator, final_args, &erl_env_map); + 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; + } } } 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..0b2255d 100644 --- a/src/wrapper.zig +++ b/src/wrapper.zig @@ -1,14 +1,10 @@ -//// -// 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; @@ -39,31 +35,31 @@ 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); + // Trim args to only what we actually want to pass to erlang + 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); + if (comptime IS_LINUX) try maybe_install_musl_runtime(io); - // 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; } @@ -72,11 +68,11 @@ pub fn main() !void { 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 { @@ -96,24 +92,24 @@ pub fn main() !void { // 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 +117,35 @@ 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 { +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(arena, FOILZ_PAYLOAD, install_dir, build_options.UNCOMPRESSED_SIZE); + 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 +// 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,9 +153,32 @@ 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 { +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 { // 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); + 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( @@ -169,13 +188,13 @@ fn get_install_dir(arena: std.mem.Allocator, meta: *const MetaStruct) ![]u8 { ); // 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 +218,28 @@ fn install_dir_error(arena: std.mem.Allocator) void { std.process.exit(1); } -fn maybe_install_musl_runtime(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; } - 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.fromMode(@intCast(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}); }