@@ -34,6 +34,105 @@ pub fn build(b: *std.Build) void {
3434 lib_options .addOption (bool , "enable_mcl" , enable_mcl );
3535 const lib_options_module = lib_options .createModule ();
3636
37+ // Helper function to remove duplicate rpaths on macOS
38+ //
39+ // ROOT CAUSE:
40+ // Zig's build system automatically adds an LC_RPATH entry for each library linked via
41+ // linkSystemLibrary(). When multiple libraries are in the same directory (e.g., libssl.3.dylib
42+ // and libcrypto.3.dylib both in /opt/homebrew/Cellar/openssl@3/3.6.0/lib), Zig adds the same
43+ // rpath multiple times, causing duplicate LC_RPATH entries that dyld rejects.
44+ //
45+ // This is a known Zig issue: https://github.com/ziglang/zig/issues/24349
46+ // System libraries shouldn't need rpaths at all since they're in standard search paths.
47+ //
48+ // WORKAROUND:
49+ // We remove duplicate rpaths using install_name_tool. Since Zig's addFileArg doesn't work
50+ // reliably with shell scripts, we use individual install_name_tool commands. We remove the
51+ // duplicate rpath twice (to handle the common case of 2 duplicates) and add it back once.
52+ // This is a temporary fix until Zig addresses the root cause upstream.
53+ //
54+ // NOTE: This hardcodes the OpenSSL rpath path. For non-Homebrew installations or different
55+ // OpenSSL versions, you may need to adjust the path or add additional rpath cleanup steps.
56+ //
57+ // TODO: Remove this workaround when Zig addresses the root cause upstream in v0.16.0+
58+ const removeDuplicateRpaths = struct {
59+ fn remove (
60+ b_ctx : * std.Build ,
61+ exe : * std.Build.Step.Compile ,
62+ run_step : ? * std.Build.Step.Run ,
63+ ) void {
64+ // Output warning about workaround in yellow
65+ std .debug .print ("\x1b [33mWarning: Removing duplicate rpaths as a workaround for a known Zig issue (https://github.com/ziglang/zig/issues/24349). This will be removed when Zig addresses the root cause upstream in v0.16.0+\x1b [0m\n " , .{});
66+
67+ const exe_target = exe .root_module .resolved_target orelse return ;
68+ if (exe_target .result .os .tag != .macos ) return ;
69+
70+ const bin_file = exe .getEmittedBin ();
71+ // Common OpenSSL rpath for Homebrew installations
72+ // Adjust this if your OpenSSL is installed elsewhere
73+ const rpath = "/opt/homebrew/Cellar/openssl@3/3.6.0/lib" ;
74+
75+ // Remove first instance (ignore errors if it doesn't exist)
76+ // Use sh -c to wrap the command so we can ignore errors with || true
77+ // Redirect stderr to /dev/null to suppress error messages
78+ // Add a dummy arg so the file path becomes $1 (first arg after -c script becomes $0)
79+ const remove1_cmd = std .fmt .allocPrint (b_ctx .allocator , "install_name_tool -delete_rpath '{s}' \" $1\" 2>/dev/null || true" , .{rpath }) catch @panic ("OOM" );
80+ const remove1 = b_ctx .addSystemCommand (&.{ "sh" , "-c" , remove1_cmd , "dummy" });
81+ remove1 .addFileArg (bin_file );
82+ remove1 .step .dependOn (& exe .step );
83+
84+ // Remove second instance (ignore errors if it doesn't exist)
85+ const remove2_cmd = std .fmt .allocPrint (b_ctx .allocator , "install_name_tool -delete_rpath '{s}' \" $1\" 2>/dev/null || true" , .{rpath }) catch @panic ("OOM" );
86+ const remove2 = b_ctx .addSystemCommand (&.{ "sh" , "-c" , remove2_cmd , "dummy" });
87+ remove2 .addFileArg (bin_file );
88+ remove2 .step .dependOn (& remove1 .step );
89+
90+ // Add it back once (only if it doesn't already exist after removal)
91+ // This ensures we have exactly one rpath if any existed before
92+ const add_back_cmd = std .fmt .allocPrint (b_ctx .allocator , "otool -l \" $1\" | grep -q \" path {s}\" || (install_name_tool -add_rpath '{s}' \" $1\" 2>/dev/null || true)" , .{ rpath , rpath }) catch @panic ("OOM" );
93+ const add_back = b_ctx .addSystemCommand (&.{ "sh" , "-c" , add_back_cmd , "dummy" });
94+ add_back .addFileArg (bin_file );
95+ add_back .step .dependOn (& remove2 .step );
96+
97+ // Make the run step depend on cleaning rpaths so it runs before execution
98+ if (run_step ) | run | {
99+ run .step .dependOn (& add_back .step );
100+ }
101+
102+ // Also add to install step so installed binaries are clean
103+ // Since we clean the build binary before install, the installed copy should be clean
104+ // But we also clean the installed binary after installation to be safe
105+ const install_step = b_ctx .getInstallStep ();
106+ install_step .dependOn (& add_back .step );
107+
108+ // Also clean the installed binary after it's copied
109+ // Get the installed binary path - use absolute path from build root
110+ const exe_name = exe .name ;
111+ // Construct absolute path: build_root/zig-out/bin/exe_name
112+ const installed_bin_path = std .fmt .allocPrint (b_ctx .allocator , "zig-out/bin/{s}" , .{exe_name }) catch @panic ("OOM" );
113+
114+ const install_remove1_cmd = std .fmt .allocPrint (b_ctx .allocator , "install_name_tool -delete_rpath '{s}' \" $1\" 2>/dev/null || true" , .{rpath }) catch @panic ("OOM" );
115+ const install_remove1 = b_ctx .addSystemCommand (&.{ "sh" , "-c" , install_remove1_cmd , "dummy" });
116+ install_remove1 .addArg (installed_bin_path );
117+ install_remove1 .step .dependOn (install_step );
118+
119+ const install_remove2_cmd = std .fmt .allocPrint (b_ctx .allocator , "install_name_tool -delete_rpath '{s}' \" $1\" 2>/dev/null || true" , .{rpath }) catch @panic ("OOM" );
120+ const install_remove2 = b_ctx .addSystemCommand (&.{ "sh" , "-c" , install_remove2_cmd , "dummy" });
121+ install_remove2 .addArg (installed_bin_path );
122+ install_remove2 .step .dependOn (& install_remove1 .step );
123+
124+ const install_add_back_cmd = std .fmt .allocPrint (b_ctx .allocator , "otool -l \" $1\" | grep -q \" path {s}\" || (install_name_tool -add_rpath '{s}' \" $1\" 2>/dev/null || true)" , .{ rpath , rpath }) catch @panic ("OOM" );
125+ const install_add_back = b_ctx .addSystemCommand (&.{ "sh" , "-c" , install_add_back_cmd , "dummy" });
126+ install_add_back .addArg (installed_bin_path );
127+ install_add_back .step .dependOn (& install_remove2 .step );
128+
129+ // Make run step also depend on installed binary cleanup
130+ if (run_step ) | run | {
131+ run .step .dependOn (& install_add_back .step );
132+ }
133+ }
134+ }.remove ;
135+
37136 // Helper function to add crypto library linking to a step
38137 const addCryptoLibraries = struct {
39138 fn add (
@@ -297,8 +396,17 @@ pub fn build(b: *std.Build) void {
297396 test_exe .root_module .addImport ("handler" , handler_module );
298397 test_exe .root_module .addImport ("inspector" , inspector_module );
299398
399+ // Run tests
400+ const run_tests = b .addRunArtifact (test_exe );
401+
402+ // Remove duplicate rpaths on macOS before installation and running tests
403+ removeDuplicateRpaths (b , test_exe , run_tests );
404+
300405 b .installArtifact (test_exe );
301406
407+ const test_step = b .step ("test" , "Run unit tests" );
408+ test_step .dependOn (& run_tests .step );
409+
302410 // Benchmark executable
303411 const bench_exe = b .addExecutable (.{
304412 .name = "zevm-bench" ,
@@ -323,11 +431,6 @@ pub fn build(b: *std.Build) void {
323431
324432 b .installArtifact (bench_exe );
325433
326- // Run tests
327- const run_tests = b .addRunArtifact (test_exe );
328- const test_step = b .step ("test" , "Run unit tests" );
329- test_step .dependOn (& run_tests .step );
330-
331434 // Inline zig tests for interpreter module (discovers tests in all imported files)
332435 const interpreter_tests = b .addTest (.{
333436 .root_module = b .createModule (.{
0 commit comments