From ea1f6cad6f595b41fe2cfaedefb9c53748c6cf92 Mon Sep 17 00:00:00 2001 From: Niclas Overby Date: Sat, 15 Aug 2026 05:03:38 +0200 Subject: [PATCH] feat(cider): what iTerm2 would take, measured rather than guessed The second north star, tried for the first time. It does not start: dyld: Library not loaded: /System/Library/Frameworks/CryptoKit.framework/... The interesting part is not that it fails, it is knowing exactly why without discovering it one dyld error at a time. scripts/macho-needs.py reads the load commands of a Mach-O and answers the whole question at once. For iTerm2 3.6.10, x86_64 slice: needs=89 present=63 in-bundle=10 missing=9 weak-missing=7 THE NINE FALL INTO EXACTLY TWO GROUPS. CryptoKit, QuickLookUI, ScreenCaptureKit, SwiftUI system frameworks, none in this tree libswift_Concurrency, libswiftSystem, Swift runtime, 5.5 and later libswiftSystem_Foundation, libswiftUniformTypeIdentifiers, libswiftWebKit THE SECOND GROUP IS ONE PIN BUMP. vendor/pins/swift is version.txt 5.2.2 and its build.sh extracts the dylibs straight out of an official swift.org release package. Every missing library there belongs to a later Swift, so moving the pin forward should supply all five with no new code. That is the tractable next step and it should come before anything else here. THE FIRST GROUP IS REAL WORK. SwiftUI is not a stub anyone writes in an afternoon, and a framework that loads while exporting nothing does not help: a two level namespace binds classes and data at load time, so it fails on the first symbol the application really uses. THE TOOL EARNS ITS PLACE TWICE. It resolves rpath and executable_path, because treating those as paths turns the ten frameworks iTerm2 ships INSIDE ITS OWN BUNDLE into missing ones and buries the four that matter. And it reads the MAGIC rather than asking whether the file exists: the forty four swift dylibs in the runtime tree are 131 byte git lfs POINTER FILES here and real Mach-O only in the nix pin, so an existence check reports a complete Swift runtime that dyld cannot load. It said not-macho=23 before the real ones were copied in from the pin, 0 after. On the bundle: the nixpkgs recipe points at the iTerm2 stable URL with a hash that no longer matches what the URL serves, so this one was fetched with its real hash. The version inside is still 3.6.10. --- docs/wayland-port.md | 43 +++++++++++++ scripts/macho-needs.py | 139 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 scripts/macho-needs.py diff --git a/docs/wayland-port.md b/docs/wayland-port.md index 8a8ad5135..b3a644798 100644 --- a/docs/wayland-port.md +++ b/docs/wayland-port.md @@ -2702,3 +2702,46 @@ in the check, because the check applied it to the same already-patched pin. Regenerate against a pristine pin: an older cider-src store path from before the patch existed, or reverse the committed patch first. And the verification has to apply the WHOLE SERIES to a pristine tree, which is what caught it. + +## ITERM2: WHAT IT WOULD TAKE, MEASURED RATHER THAN GUESSED + +The second north star, tried for the first time. It does not start. The interesting part is not that, +it is knowing exactly why without discovering it one dyld error at a time. + + dyld: Library not loaded: /System/Library/Frameworks/CryptoKit.framework/... + +scripts/macho-needs.py reads the load commands of a Mach-O and answers the whole question at once, +which is the difference between one round trip and thirty. For iTerm2 3.6.10, x86_64 slice: + + needs=89 present=63 in-bundle=10 missing=9 weak-missing=7 + +THE NINE, and they fall into exactly two groups: + + /System/Library/Frameworks/CryptoKit not in this tree, and not in Darling either + /System/Library/Frameworks/QuickLookUI + /System/Library/Frameworks/ScreenCaptureKit + /System/Library/Frameworks/SwiftUI + + /usr/lib/swift/libswift_Concurrency Swift 5.5 and later + /usr/lib/swift/libswiftSystem swift-system, 5.6 and later + /usr/lib/swift/libswiftSystem_Foundation + /usr/lib/swift/libswiftUniformTypeIdentifiers + /usr/lib/swift/libswiftWebKit + +THE SECOND GROUP IS ONE PIN BUMP. vendor/pins/swift is version.txt 5.2.2, and its build.sh extracts +the dylibs straight out of an official swift.org release package. Every missing library there +belongs to a LATER Swift, so moving that pin forward should supply all five at once, with no new +code. That is a tractable next step and it should be taken before anything else here. + +THE FIRST GROUP IS REAL WORK. SwiftUI in particular is not a stub anyone writes in an afternoon, and +a framework that loads while exporting nothing does not help: a two level namespace binds classes +and data at load time, so it fails at the first symbol the application really uses. + +AND A LOCAL TRAP WORTH KNOWING. The forty four swift dylibs in the runtime tree are 131 byte GIT LFS +POINTER FILES here and real Mach-O only in the nix pin. Anything asking whether the file EXISTS +reports a complete Swift runtime; dyld disagrees. macho-needs.py reads the magic for that reason and +reported not-macho=23 before the real ones were copied in, 0 after. + +The version of iTerm2 is worth stating too: the nixpkgs recipe points at the stable URL with a hash +that no longer matches what that URL serves, so the bundle here was fetched with its real hash. The +version inside it is still 3.6.10, the same as nixpkgs claims. diff --git a/scripts/macho-needs.py b/scripts/macho-needs.py new file mode 100644 index 000000000..250ce8d18 --- /dev/null +++ b/scripts/macho-needs.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""What a Mach-O binary asks the loader for, and which of those this prefix has. + +ONE ERROR AT A TIME IS THE SLOW WAY. A bundle that links forty frameworks fails on the first one +missing, gets that one added, and fails on the next; each round is a build and a run. The load +commands list every dependency up front, so the whole gap can be seen at once and the work ordered +by what actually matters. + +Handles FAT binaries by walking every architecture and reporting the x86_64 slice, which is the one +this port runs. Follows nothing: a dependency of a dependency is a separate question, and asking it +here would hide which of them the application itself named. + + scripts/macho-needs.py [prefix] [runtime] +""" +import struct +import sys + +FAT_MAGIC = 0xCAFEBABE +FAT_CIGAM = 0xBEBAFECA +MH_MAGIC_64 = 0xFEEDFACF +MH_CIGAM_64 = 0xCFFAEDFE +LC_LOAD_DYLIB = 0x0C +LC_LOAD_WEAK_DYLIB = 0x80000018 +LC_REEXPORT_DYLIB = 0x8000001F +LC_LOAD_UPWARD_DYLIB = 0x80000023 +CPU_TYPE_X86_64 = 0x01000007 + + +def slices(data): + """(offset, cputype) for each architecture in the file, thin or fat.""" + magic = struct.unpack(">I", data[:4])[0] + if magic in (FAT_MAGIC, FAT_CIGAM): + count = struct.unpack(">I", data[4:8])[0] + out = [] + for i in range(count): + base = 8 + i * 20 + cputype, _sub, offset, _size, _align = struct.unpack(">iiIII", data[base:base + 20]) + out.append((offset, cputype)) + return out + return [(0, CPU_TYPE_X86_64)] + + +def dylibs(data, offset): + magic = struct.unpack(" 2 else "/tmp/cider-appkit-1000/prefix" + runtime = sys.argv[3] if len(sys.argv) > 3 else "/tmp/cider-appkit-1000/rt/libexec/cider" + + with open(path, "rb") as handle: + data = handle.read() + + wanted = [] + for offset, cputype in slices(data): + if cputype != CPU_TYPE_X86_64: + continue + wanted = dylibs(data, offset) + break + + import os + + # @rpath AND @executable_path ARE NOT PATHS, and treating them as if they were turns every + # framework an application ships INSIDE ITS OWN BUNDLE into a missing one. iTerm2 has ten of + # those, and a report that names them alongside SwiftUI is worse than no report: it buries the + # four that matter under six that are already there. + exe_dir = os.path.dirname(os.path.abspath(path)) + bundle_roots = [exe_dir, os.path.join(exe_dir, "..", "Frameworks")] + + def resolve(name): + if name.startswith("@executable_path/"): + return [os.path.normpath(os.path.join(exe_dir, name[len("@executable_path/"):]))] + if name.startswith("@loader_path/"): + return [os.path.normpath(os.path.join(exe_dir, name[len("@loader_path/"):]))] + if name.startswith("@rpath/"): + tail = name[len("@rpath/"):] + return [os.path.normpath(os.path.join(root, tail)) for root in bundle_roots] + # The guest sees a union of the prefix over the runtime tree, so either one counts. + return [root + name for root in (prefix, runtime)] + + def is_macho(candidate): + """EXISTING IS NOT THE SAME AS LOADABLE. + + The swift dylibs in this tree are 131 byte git-lfs POINTER FILES locally and real Mach-O + only in the nix pin, so a check that asks whether the path exists reports a runtime that is + entirely there and dyld disagrees. Read the magic.""" + try: + with open(candidate, "rb") as handle: + head = handle.read(4) + except OSError: + return False + return head in (b"\xcf\xfa\xed\xfe", b"\xce\xfa\xed\xfe", b"\xca\xfe\xba\xbe", + b"\xbe\xba\xfe\xca") + + missing, weak_missing, present, bundled, notmacho = [], [], [], [], [] + for name, weak in wanted: + candidates = resolve(name) + found = any(os.path.exists(candidate) for candidate in candidates) + loadable = any(is_macho(candidate) for candidate in candidates) + if found and not loadable: + notmacho.append(name) + elif loadable: + (bundled if name.startswith("@") else present).append(name) + elif weak: + weak_missing.append(name) + else: + missing.append(name) + + print(f"needs={len(wanted)} present={len(present)} in-bundle={len(bundled)} " + f"missing={len(missing)} not-macho={len(notmacho)} weak-missing={len(weak_missing)}") + for name in notmacho: + print(f"NOTMACHO {name}") + for name in missing: + print(f"MISSING {name}") + for name in weak_missing: + print(f"weak {name}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) -- 2.51.2