Skip to content

Commit b2d24b5

Browse files
committed
fix(deps): work-around duplicate RPATHs in libgccjit from Nix
This is a horrible hack to work around macOS 15.4 no longer allowing the use of shared libraries which have duplicate RPATHs. Annoying libgccjit from Nix has two sets of duplicate RPATHs. Hence the fix is to check the RPATHs of `libgccjit*.dylib`, remove any duplicates, and also ensure there's no duplicates in CFLAGS, LDFLAGS, or LIBRARY_PATHS which can cause libgccjit to output new compiled code with duplicate RPATHs. If needed, it will trigger sudo to modify the `libgccjit*.dylib` file within the Nix store. It does create a `.bak` backup file, so you can restore it if needed.
1 parent bc2a457 commit b2d24b5

File tree

3 files changed

+244
-24
lines changed

3 files changed

+244
-24
lines changed

build-emacs-for-macos

Lines changed: 230 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ require 'net/http'
1212
require 'open3'
1313
require 'optparse'
1414
require 'pathname'
15+
require 'set'
1516
require 'time'
1617
require 'tmpdir'
1718
require 'uri'
@@ -532,7 +533,23 @@ class Build
532533
env += ENV['NIX_CFLAGS_COMPILE'].split
533534
end
534535

535-
@env_CFLAGS = env
536+
# Group "-isystem <path>" flags together as a single flag.
537+
#
538+
# This allows us to deduplicate CFLAGS from NIX_CFLAGS_COMPILE.
539+
new_env = []
540+
isystem_flag = false
541+
env.each do |flag|
542+
if flag.strip == '-isystem'
543+
isystem_flag = true
544+
elsif isystem_flag
545+
new_env << "-isystem #{flag}"
546+
isystem_flag = false
547+
else
548+
new_env << flag
549+
end
550+
end
551+
552+
@env_CFLAGS = new_env
536553
end
537554

538555
def env_LDFLAGS
@@ -623,11 +640,11 @@ class Build
623640

624641
if options[:native_comp]
625642
env['CFLAGS'] = [env_CFLAGS, ENV.fetch('CFLAGS', nil)]
626-
.flatten.compact.reject(&:empty?).join(' ')
643+
.flatten.compact.reject(&:empty?).uniq.join(' ')
627644
env['LDFLAGS'] = [env_LDFLAGS, ENV.fetch('LDFLAGS', nil)]
628-
.flatten.compact.reject(&:empty?).join(' ')
645+
.flatten.compact.reject(&:empty?).uniq.join(' ')
629646
env['LIBRARY_PATH'] = [env_LIBRARY_PATH, ENV.fetch('LIBRARY_PATH', nil)]
630-
.flatten.compact.reject(&:empty?).join(':')
647+
.flatten.compact.reject(&:empty?).uniq.join(':')
631648
end
632649

633650
@compile_env = env
@@ -1701,6 +1718,7 @@ end
17011718

17021719
class GccInfo
17031720
include Output
1721+
include System
17041722

17051723
def initialize(use_nix: false)
17061724
@use_nix = use_nix
@@ -1823,7 +1841,7 @@ class GccInfo
18231841
Dir[
18241842
File.join(libgccjit_root_dir, 'lib/gcc/*/libgccjit*.dylib'),
18251843
File.join(libgccjit_root_dir, 'lib/gcc/*/libgccjit.so*'),
1826-
]
1844+
]
18271845
.map { |path| File.dirname(path) }
18281846
.select { |path| File.basename(path).match(/^\d+$/) }
18291847
.max_by { |path| File.basename(path).to_i }
@@ -1840,10 +1858,15 @@ class GccInfo
18401858
'brew reinstall libgccjit'
18411859
end
18421860

1843-
# No need to verify gcc vs libgccjit for Nix, as we can pull everything we
1844-
# need from the libgccjit package. On homebrew we need to pull parts from
1845-
# gcc and parts from libgccjit, hence we need to ensure versions match.
1846-
return if use_nix?
1861+
if use_nix?
1862+
Dir[File.join(libgccjit_lib_dir, 'libgccjit*.dylib')]
1863+
.each { |path| clean_macho_binary(path) }
1864+
1865+
# No need to verify gcc vs libgccjit for Nix, as we can pull everything we
1866+
# need from the libgccjit package. On homebrew we need to pull parts from
1867+
# gcc and parts from libgccjit, hence we need to ensure versions match.
1868+
return
1869+
end
18471870

18481871
return if major_version == libgccjit_major_version
18491872

@@ -1868,6 +1891,186 @@ class GccInfo
18681891
def relative_path(base, path)
18691892
Pathname.new(path).relative_path_from(Pathname.new(base)).to_s
18701893
end
1894+
1895+
def clean_macho_binary(path)
1896+
debug "Checking for duplicate RPATHs in #{path}"
1897+
macho_cleaner = MachOCleaner.new(path)
1898+
return unless macho_cleaner.has_duplicate_rpaths?
1899+
1900+
begin
1901+
info "Removing duplicate RPATHs from #{path}"
1902+
macho_cleaner.clean!
1903+
debug 'Cleaned duplicate RPATHs successfully!'
1904+
rescue MachOCleaner::PermissionError => e
1905+
warn "Could not remove duplicate RPATHs from #{path}: #{e.message}"
1906+
if ENV['USER'] == 'root'
1907+
fatal "Could not remove duplicate RPATHs from #{path}: #{e.message}"
1908+
else
1909+
warn '================================================================='
1910+
warn "Attempting to clean duplicate RPATHs from #{path} as root"
1911+
warn '================================================================='
1912+
run_cmd('sudo', $PROGRAM_NAME, '--clean-macho-binary', path)
1913+
end
1914+
end
1915+
end
1916+
end
1917+
1918+
# MachOCleaner is a class that cleans up a Mach-O file by removing all duplicate
1919+
# RPATH load commands. This ensures compatibility with macOS 15.4 and later,
1920+
# which refuses to load binaries and shared libraries with duplicate RPATHs.
1921+
class MachOCleaner
1922+
include Output
1923+
include System
1924+
1925+
class PermissionError < StandardError
1926+
def initialize(file, message = nil)
1927+
@file = file
1928+
super(message || "Insufficient permissions to modify #{file}")
1929+
end
1930+
1931+
attr_reader :file
1932+
end
1933+
1934+
attr_reader :file
1935+
1936+
def initialize(file_path, backup: true)
1937+
@file = file_path
1938+
@backup = backup
1939+
1940+
validate_file!
1941+
end
1942+
1943+
def backup?
1944+
@backup
1945+
end
1946+
1947+
# Main cleaning method - removes duplicate RPATH commands
1948+
def clean!
1949+
duplicate_paths = find_duplicate_rpaths(macho_object)
1950+
return if duplicate_paths.empty?
1951+
1952+
backup_file! if backup?
1953+
1954+
while_writable(@file) do
1955+
duplicate_paths.each do |rpath|
1956+
remove_rpath_with_install_name_tool!(rpath)
1957+
end
1958+
end
1959+
end
1960+
1961+
# Check if file has duplicate RPATH commands
1962+
def has_duplicate_rpaths?
1963+
count_duplicate_rpaths(macho_object).positive?
1964+
end
1965+
1966+
# Return total number of RPATH commands
1967+
def rpath_count
1968+
count_rpaths(macho_object)
1969+
end
1970+
1971+
# Return number of duplicate RPATH commands
1972+
def duplicate_rpath_count
1973+
count_duplicate_rpaths(macho_object)
1974+
end
1975+
1976+
private
1977+
1978+
# Validate that the file exists and is readable
1979+
def validate_file!
1980+
fatal "File does not exist: #{@file}" unless File.exist?(@file)
1981+
return if File.readable?(@file)
1982+
1983+
fatal "File is not readable: #{@file}"
1984+
end
1985+
1986+
# Load and memoize the Mach-O object
1987+
def macho_object
1988+
return @macho_object if @macho_object
1989+
1990+
begin
1991+
@macho_object = MachO.open(@file)
1992+
rescue MachO::MachOError => e
1993+
fatal "Not a valid Mach-O file: #{@file} (#{e.message})"
1994+
end
1995+
1996+
unless @macho_object.respond_to?(:rpaths)
1997+
fatal "Unsupported Mach-O file type: #{@file}"
1998+
end
1999+
2000+
@macho_object
2001+
end
2002+
2003+
def backup_file!
2004+
backup_file = "#{@file}.bak"
2005+
if File.exist?(backup_file)
2006+
debug "Backup file already exists: #{backup_file}"
2007+
return
2008+
end
2009+
2010+
FileUtils.cp(@file, backup_file)
2011+
debug "Backed up #{@file} to #{backup_file}"
2012+
end
2013+
2014+
# Temporarily make file writable, execute block, then restore permissions
2015+
def while_writable(file)
2016+
# Check if file is already writable to avoid unnecessary permission changes
2017+
if File.writable?(file)
2018+
yield
2019+
return
2020+
end
2021+
2022+
original_mode = File.stat(file).mode
2023+
2024+
begin
2025+
File.chmod(0o755, file)
2026+
rescue Errno::EPERM, Errno::EACCES => e
2027+
raise PermissionError.new(
2028+
file, "Cannot change file permissions: #{e.message}"
2029+
)
2030+
end
2031+
2032+
yield
2033+
ensure
2034+
if File.exist?(file) && original_mode
2035+
begin
2036+
File.chmod(original_mode, file)
2037+
rescue Errno::EPERM, Errno::EACCES
2038+
# Log warning but don't fail - file was already modified successfully
2039+
warn "Warning: Could not restore original permissions for #{file}"
2040+
end
2041+
end
2042+
end
2043+
2044+
# Find duplicate RPATH commands in a Mach-O file
2045+
def find_duplicate_rpaths(macho_file)
2046+
seen = Set.new
2047+
duplicates = []
2048+
2049+
macho_file.rpaths.each do |rpath|
2050+
if seen.include?(rpath)
2051+
duplicates << rpath
2052+
else
2053+
seen.add(rpath)
2054+
end
2055+
end
2056+
2057+
duplicates
2058+
end
2059+
2060+
# Remove an RPATH using install_name_tool
2061+
def remove_rpath_with_install_name_tool!(rpath)
2062+
run_cmd('install_name_tool', '-delete_rpath', rpath, @file)
2063+
end
2064+
2065+
# Count total RPATH commands in a Mach-O file
2066+
def count_rpaths(macho_file)
2067+
macho_file.rpaths.size
2068+
end
2069+
2070+
# Count duplicate RPATH commands in a Mach-O file
2071+
def count_duplicate_rpaths(macho_file)
2072+
find_duplicate_rpaths(macho_file).size
2073+
end
18712074
end
18722075

18732076
class CLIOptions
@@ -1912,7 +2115,8 @@ class CLIOptions
19122115
archive: true,
19132116
archive_keep: false,
19142117
patches: [],
1915-
log_level: 'info'
2118+
log_level: 'info',
2119+
clean_macho_binary: nil
19162120
}
19172121
end
19182122

@@ -2128,6 +2332,11 @@ class CLIOptions
21282332
'--plan FILE',
21292333
'Follow given plan file, instead of using given git ref/sha'
21302334
) { |v| options[:plan] = v }
2335+
2336+
opts.on(
2337+
'--clean-macho-binary FILE',
2338+
'Tool to clean duplicate RPATHs from given Mach-O binary.'
2339+
) { |v| options[:clean_macho_binary] = v }
21312340
end
21322341
end
21332342
end
@@ -2144,6 +2353,17 @@ if __FILE__ == $PROGRAM_NAME
21442353
build.print_info
21452354
elsif cli_options[:preview]
21462355
build.print_preview
2356+
elsif cli_options[:clean_macho_binary]
2357+
macho_cleaner = MachOCleaner.new(cli_options[:clean_macho_binary])
2358+
2359+
if macho_cleaner.has_duplicate_rpaths?
2360+
build.info 'Removing duplicate RPATHs from ' \
2361+
"#{cli_options[:clean_macho_binary]}..."
2362+
macho_cleaner.clean!
2363+
build.info 'Cleaned duplicate RPATHs successfully!'
2364+
else
2365+
build.info 'No duplicate RPATHs found.'
2366+
end
21472367
else
21482368
build.build
21492369
end

flake.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

flake.pkgs

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ cctools-binutils-darwin-wrapper-1010.6
99
clang-16.0.6
1010
clang-wrapper-16.0.6
1111
coreutils-9.5
12-
curl-8.11.0
12+
curl-8.12.1
1313
dbus-1.14.10
1414
diffutils-3.10
15-
expat-2.6.4
15+
expat-2.7.1
1616
file-5.45
1717
findutils-4.10.0
1818
fontconfig-2.15.0
@@ -23,7 +23,7 @@ gcc-wrapper-13.3.0
2323
gdk-pixbuf-2.42.12
2424
gettext-0.21.1
2525
giflib-5.2.2
26-
git-2.47.0
26+
git-2.47.2
2727
glib-2.82.1
2828
gnugrep-3.11
2929
gnumake-4.4.1
@@ -38,29 +38,29 @@ krb5-1.21.3
3838
lcms2-2.16
3939
libdeflate-1.22
4040
libgccjit-13.3.0
41-
libiconv-107
41+
libiconv-109
4242
libidn2-2.3.7
4343
libjpeg-turbo-3.0.4
4444
libpng-apng-1.6.43
4545
libpsl-0.21.5
4646
librsvg-2.58.3
47-
libtasn1-4.19.0
47+
libtasn1-4.20.0
4848
libtiff-4.7.0
4949
libwebp-1.4.0
50-
libxml2-2.13.4
50+
libxml2-2.13.8
5151
mailutils-3.17
5252
nettle-3.10
5353
nghttp2-1.64.0
54-
openssl-3.3.2
54+
openssl-3.3.3
5555
patch-2.7.6
5656
pkg-config-wrapper-0.29.2
57-
python3-3.12.7
58-
rsync-3.3.0
59-
ruby-3.3.5
57+
python3-3.12.8
58+
rsync-3.4.1
59+
ruby-3.3.8
6060
sqlite-3.46.1
6161
texinfo-7.1.1
6262
time-1.9
63-
tree-sitter-0.24.3
63+
tree-sitter-0.24.6
6464
which-2.21
6565
xcbuild-0.1.1-unstable-2019-11-20
6666
xz-5.6.3

0 commit comments

Comments
 (0)