Environment
- Skip CLI 1.9.5 (Homebrew), skip-fuse 1.0.2, skip-bridge (0.x)
- Swift 6.3.3, Swift Android SDK
swift-6.3.3-RELEASE_android, NDK r27d
- Xcode 26.5, macOS 15 / arm64
- Native/fused mode app, multi-module package
- Third-party JNI dependency:
frameo-net/swift-okhttp 0.1.1, which is built on swiftlang/swift-java 0.4.2 / swift-java-jni-core 0.5.1
Symptom
A Skip Fuse app that calls into any swift-java-generated wrapper crashes on the first bridged call:
F SwiftRuntime: SwiftJNI/SwiftJNI.swift:1390: Fatal error: Unexpectedly found nil
while implicitly unwrapping an Optional value
F libc : Fatal signal 5 (SIGTRAP), code 1 (TRAP_BRKPT)
Line 1390 is JNI.jni.withEnv { ... } inside String.fromJavaObject — swift-java's
global JNI handle is nil.
The app itself builds, links and launches correctly; the Gradle app build honours the
module's skip.yml dependency block, so the Java classes the wrapper needs are on the
classpath (an earlier ClassNotFoundException disappeared once that was declared).
It is not a missing JNI_OnLoad
swift-java initialises that handle in JNI_OnLoad, which the JVM invokes only for
libraries it loads itself. The obvious theory is that Skip's loader bypasses it, so we
forced the load from Kotlin in AndroidAppMain.onCreate():
for (lib in listOf("SwiftJava", "SwiftJNI", "FormsAPI")) {
try { System.loadLibrary(lib); logger.info("ok: $lib") }
catch (e: Throwable) { logger.info("failed: $lib -> $e") }
}
All three load successfully — so JNI_OnLoad does run — and the crash is
unchanged.
Analysis: the runtime is duplicated across module .sos
llvm-nm over the merged native libs shows SwiftJNI statically linked into seven
separate shared objects:
libSkipUI.so 4859 symbols
libSwiftJNI.so 1766
libSkipBridge.so 705
libSkipAndroidBridge.so 385
libFormsAPI.so 125
libFormsDemo.so 72
libSkipFuseUI.so 24
and three of them export their own JNI_OnLoad:
libFormsAPI.so: T JNI_OnLoad (alongside $s9SwiftJava0aB11_JNI_OnLoad...)
libSkipBridge.so: T JNI_OnLoad
libSwiftJava.so: T JNI_OnLoad
So each .so carries its own copy of SwiftJNI's global state. The JVM initialises
whichever copy belongs to the library it loaded; the copy that libFormsAPI.so's code
actually reads is a different instance and stays nil. No single initialisation — forced or
otherwise — can cover them all.
This looks like the runtime counterpart of #714: there, static-library duplication across
Skip's dynamic bridge products became a hard error under Swift 6.4's SwiftPM; here the same
duplication is silent at build time and fatal at runtime, because the duplicated library
happens to own process-global state.
Impact
Any Skip Fuse module that depends on a swift-java-based package is affected, since
swift-java is the standard way to wrap a Java library for Swift on Android. In our case
this blocks using swift-okhttp as an HTTP transport for a Swift OpenAPI client — the
generated client and its DTOs work correctly under Fuse (including polymorphic oneOf
types bridged to Kotlin), and only the transport fails.
Suggested direction
Link swift-jni / swift-java once and share it — e.g. force it dynamic (the same
SKIP_BRIDGE product gate skip-lib already uses) so a single copy of its globals exists
per process, rather than statically embedding it into every module .so.
More generally: any third-party Swift package with process-global state that is initialised
from JNI_OnLoad will break the same way under the current per-module linking, so a
documented rule about what Skip Fuse can and cannot statically duplicate would help.
Reproducing
Minimal shape:
skip init --native-app project.
- A module depending on
.product(name: "OpenAPIOkHttp", package: "swift-okhttp", condition: .when(platforms: [.android])).
- Declare
implementation("com.squareup.okhttp3:okhttp:5.3.2") in that module's skip.yml.
- Call
OkHttpClient() from Swift on launch.
Happy to share a reproducer repo or run further diagnostics if useful.
Environment
swift-6.3.3-RELEASE_android, NDK r27dframeo-net/swift-okhttp0.1.1, which is built onswiftlang/swift-java0.4.2 /swift-java-jni-core0.5.1Symptom
A Skip Fuse app that calls into any
swift-java-generated wrapper crashes on the first bridged call:Line 1390 is
JNI.jni.withEnv { ... }insideString.fromJavaObject—swift-java'sglobal JNI handle is nil.
The app itself builds, links and launches correctly; the Gradle app build honours the
module's
skip.ymldependency block, so the Java classes the wrapper needs are on theclasspath (an earlier
ClassNotFoundExceptiondisappeared once that was declared).It is not a missing
JNI_OnLoadswift-javainitialises that handle inJNI_OnLoad, which the JVM invokes only forlibraries it loads itself. The obvious theory is that Skip's loader bypasses it, so we
forced the load from Kotlin in
AndroidAppMain.onCreate():All three load successfully — so
JNI_OnLoaddoes run — and the crash isunchanged.
Analysis: the runtime is duplicated across module
.sosllvm-nmover the merged native libs showsSwiftJNIstatically linked into sevenseparate shared objects:
and three of them export their own
JNI_OnLoad:So each
.socarries its own copy ofSwiftJNI's global state. The JVM initialiseswhichever copy belongs to the library it loaded; the copy that
libFormsAPI.so's codeactually reads is a different instance and stays nil. No single initialisation — forced or
otherwise — can cover them all.
This looks like the runtime counterpart of #714: there, static-library duplication across
Skip's dynamic bridge products became a hard error under Swift 6.4's SwiftPM; here the same
duplication is silent at build time and fatal at runtime, because the duplicated library
happens to own process-global state.
Impact
Any Skip Fuse module that depends on a
swift-java-based package is affected, sinceswift-javais the standard way to wrap a Java library for Swift on Android. In our casethis blocks using
swift-okhttpas an HTTP transport for a Swift OpenAPI client — thegenerated client and its DTOs work correctly under Fuse (including polymorphic
oneOftypes bridged to Kotlin), and only the transport fails.
Suggested direction
Link
swift-jni/swift-javaonce and share it — e.g. force it dynamic (the sameSKIP_BRIDGEproduct gateskip-libalready uses) so a single copy of its globals existsper process, rather than statically embedding it into every module
.so.More generally: any third-party Swift package with process-global state that is initialised
from
JNI_OnLoadwill break the same way under the current per-module linking, so adocumented rule about what Skip Fuse can and cannot statically duplicate would help.
Reproducing
Minimal shape:
skip init --native-appproject..product(name: "OpenAPIOkHttp", package: "swift-okhttp", condition: .when(platforms: [.android])).implementation("com.squareup.okhttp3:okhttp:5.3.2")in that module'sskip.yml.OkHttpClient()from Swift on launch.Happy to share a reproducer repo or run further diagnostics if useful.