Skip to content

_runtime.h: Foundation dlopen() always fails on modern iOS; /Groups/System/... fallback path is bogus (reprises #22, #36) #146

Description

@ElliotGarbus

Summary

pyobjus/_runtime.h::pyobjc_internal_init() tries to dlopen()
Foundation by absolute filesystem path:

foundation = dlopen(
    "/System/Library/Frameworks/Foundation.framework/Versions/Current/Foundation",
    RTLD_LAZY);

On modern iOS (iOS 16+) and the matching simulators, this call
always fails — Apple moved system frameworks fully into the dyld
shared cache and removed the on-disk copies at /System/Library/Frameworks/.
The function then prints two noisy lines to stdout on every launch:

Got dlopen error on Foundation: dlopen(/System/Library/Frameworks/Foundation.framework/Versions/Current/Foundation, 0x0001):
  tried: '…/Foundation' (no such file), …
  '/System/Library/Frameworks/Foundation.framework/Versions/Current/Foundation' (no such file, not in dyld cache), …

Got fallback dlopen error on Foundation: dlopen(/Groups/System/Library/Frameworks/Foundation.framework/Versions/Current/Foundation, 0x0001):
  …

The fact that pyobjus still works correctly afterwards is a useful
clue: nothing downstream depends on the dlopen handle.

Two closed prior issues raised this same warning a decade ago
(#22, #36); neither actually fixed the root cause. Filing fresh
with current context — Xcode 16 / iOS 18.3 simulator / dyld shared
cache architecture — and a concrete fix proposal.

Environment

  • macOS 14.8 (Darwin 23.6.0), Xcode 16.x, iPhoneOS 18.2 SDK
  • iOS 18.3 simulator (Build 22D8075)
  • pyobjus master (verified _runtime.h is byte-identical to
    what's been there since 2016)
  • kivy-ios master @ 54426ed, Kivy 3.0 feature/video-avfoundation

Why the dlopen always fails on modern iOS

Starting with iOS 16 (2022), Apple removed the on-disk copies of
system frameworks from the device filesystem. They now live
only in the dyld shared cache — a single mmap'd blob that
contains pre-linked images of every system library. The simulator
runtime followed suit shortly after; on iOS 18.3 simulator there
is no file at /System/Library/Frameworks/Foundation.framework/Versions/Current/Foundation,
just like on a real device.

dlopen() of an absolute path that doesn't exist on disk fails,
regardless of code signing or entitlements. The dyld shared cache
provides Foundation to every process by default, but only via
direct linking and dlopen(NULL, …) / dlsym lookup — not via
absolute filesystem dlopen.

The runtime symptom is documented in
Apple's iOS 16 release notes and reproducible on
any iOS 16+ device or simulator.

Why it's "harmless" today

Apps that link pyobjus also link Foundation through the normal
linker path (-framework Foundation, declared in kivy-ios's objc
recipe pbx_frameworks). By the time pyobjc_internal_init() runs,
Foundation is already mapped into the process and every Objective-C
class it defines (NSAutoreleasePool, NSString, …) is reachable
through the Objective-C runtime (objc_getClass, class_createInstance,
objc_msgSend).

The dlopen handle is not subsequently read in the current code —
_runtime.h stores it in a static variable and returns. The next
function in the file, allocAndInitAutoreleasePool(), reaches
NSAutoreleasePool via objc_getClass("NSAutoreleasePool"), which
goes through the Objective-C runtime's class registry and doesn't
depend on the dlopen handle. So when the call fails, downstream
ObjC operations continue to work.

Why it should still be fixed

  1. Noise. Two scary-looking dlopen error lines on every app
    launch are confusing to anyone reading device logs for the first
    time. They look like a real failure but aren't. New users
    regularly file bugs against kivy-ios and kivy thinking
    something is broken.

  2. /Groups/System/... is a bogus path. The fallback
    dlopen("/Groups/System/Library/Frameworks/Foundation.framework/...")
    does not correspond to any real directory on any version of macOS
    or iOS. It looks like a typo — possibly /System/Library/...
    typed with the wrong autocomplete, or a paste from somewhere
    else's filesystem layout. Got dlopen error on Foundation #22 (closed 2016-10-28) flagged this
    exact bogus path a decade ago; it was never fixed.

  3. Future-proofing. Apple has been progressively tightening
    absolute-path dlopen over the last several releases (sandbox
    restrictions, library validation, hardened runtime, etc.).
    Simplifying the init path now reduces the chance that a future
    iOS / macOS release introduces a new failure mode here.

  4. macOS sandbox / Mac App Store. On sandboxed macOS apps the
    absolute-path dlopen of a system framework can also fail
    depending on temporary-exception entitlements. The function's
    silent-failure behavior masks this from developers who would
    otherwise want to know.

Suggested fix

Delete the body of pyobjc_internal_init():

static void pyobjc_internal_init() {
    // Foundation is provided by the dyld shared cache on iOS and by
    // direct linking on macOS. The Objective-C runtime (objc_getClass
    // etc.) works without an explicit dlopen handle, so no action is
    // needed here.
}

Or remove the function and its single call site in
pyobjus.pyx:70 entirely. Either way the result is the same.

Why this is safe

There is no compatibility consideration that requires keeping the
dlopen mechanism:

  1. The function is private. static storage class makes it
    translation-unit-local. Not part of any external API; no
    downstream caller can reference it.

  2. The handle is unused. The static void *foundation
    variable in _runtime.h is the only place the dlopen result is
    stored. It's written once at init time and never read by
    anything. Verified by searching the pyobjus tree:

    $ grep -rn 'foundation' pyobjus/
    pyobjus/_runtime.h:10:    static void *foundation = NULL;      # decl
    pyobjus/_runtime.h:11:    if ( foundation == NULL ) {          # write
    pyobjus/_runtime.h:12:        foundation = dlopen(             # write
    pyobjus/_runtime.h:14:        if ( foundation == NULL ) {      # write
    pyobjus/_runtime.h:19:            foundation = dlopen(         # write
    pyobjus/_runtime.h:21:            if ( foundation == NULL ) {  # write
    

    All writes, no reads.

  3. Foundation is always already loaded by the time
    pyobjc_internal_init() runs.
    pyobjus declares Foundation in
    its own frameworks list (pyobjus/dylib_manager.py:105,
    pyobjus/consts/__init__.py:74) and on kivy-ios is also
    linked into the app via the objc recipe's pbx_frameworks.
    dyld loads Foundation when import pyobjus triggers the C
    extension load — strictly before the Python-visible
    pyobjc_internal_init() call at pyobjus.pyx:70 runs.

The only behavioral change from removing the call is that two
stdout lines disappear from every launch.

Related (closed without fix)

  • Got dlopen error on Foundation #22 — "Got dlopen error on Foundation" (closed 2016-10-28).
    Reporter flagged the bogus /Groups/System/... fallback path.
    Closed as "completed" but the typo was never corrected. Two
    separate users posted "I'm still hitting this" follow-ups in
    2017 and 2019.
  • dlopen error on Foundation #36 — "dlopen error on Foundation" (closed 2016-11-08).
    Discussion pivoted to a separate-but-related symptom (a
    sys.stdout/sys.stderr fake-redirect block in the kivy-ios
    cookiecutter main.m swallowing logs). The fake-redirect was
    conditionalized but the underlying dlopen warning was dismissed
    with "this only happens in the simulator, as the log message now
    indicates." That dismissal is no longer accurate post-iOS-16 —
    the path doesn't exist on devices either.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions