Skip to content

Expose the UserDefaults projection getter to JNI - #44

Open
shial4 wants to merge 1 commit into
skiptools:mainfrom
shial4:codex/fix-userdefaults-projection
Open

shial4 wants to merge 1 commit into
skiptools:mainfrom
shial4:codex/fix-userdefaults-projection

Conversation

@shial4

@shial4 shial4 commented Oct 24, 2026 •

Copy link
Copy Markdown

Returning native UserDefaults.standard to Kotlin through Any can trap in the generated JNI getter lookup. This is the object-projection path used by AndroidUserDefaults.toJavaObject; ordinary native reads and writes exercise different accessors.

The generated bridge requests getUserDefaults, while Kotlin mangles an internal property getter. Make the read-only UserDefaultsAccess.userDefaults property public so the getter has the expected name.

The regression returns the defaults object from native Swift, writes through the projected Kotlin object, reads the value from Swift, and removes its unique test key.

Validation

  • CI passed for bfbb3c0, including host/Robolectric, iOS, and Android emulator steps.
  • Local Android API 34 ARM64: all 20 bridge/sample tests passed.
  • Host swift test --skip XCSkipTests: 20 tests, 9 expected platform skips, no failures.
  • Robolectric: 15 passed, 5 platform skips, no failures; the new regression passed. This local run used a temporary symlink to align an existing native-library output/loader path mismatch; the symlink was removed afterward.
  • Reverting only the property visibility makes the regression crash in AndroidUserDefaults_Bridge.swift. Restoring it passes the full Android suite. Both runs used the same local generator with the separate harness changes in Run native Swift Testing on the Android main run loop skipstone#311.

The saved crash excerpt, reproduction command, and passing test totals are included in the discussion.

Checklist

  • Signed the Contributor Agreement; the current CLA check passes.
  • Tested locally with swift test and the Android checks described above.
  • Tested on iOS through CI.
  • Tested on an Android emulator.
  • AI assistance: Codex assisted with implementation, regression tests, review, and automated validation.

@cla-bot

cla-bot Bot commented Oct 24, 2026

Copy link
Copy Markdown

Thank you for your pull request and welcome to our community. We could not parse the GitHub identity of the following contributors: Szymon.
This is most likely caused by a git client misconfiguration; please make sure to:

  1. check if your git client is configured with an email to sign commits git config --list | grep email
  2. If not, set it up using git config --global user.email email@example.com
  3. Make sure that the git commit email is configured in your GitHub account settings, see https://github.com/settings/emails

@shial4
shial4 force-pushed the codex/fix-userdefaults-projection branch from 013913d to bfbb3c0 Compare October 24, 2026 12:07
@cla-bot cla-bot Bot added the cla-signed label Oct 24, 2026
@marcprux

Copy link
Copy Markdown
Member

Returning native UserDefaults.standard to Kotlin can trap while resolving getUserDefaults

Really? I'm fairly certain we've been using UserDefaults.standard in other fuse projects. What does the stack look like when it fails?

@shial4

shial4 commented Oct 24, 2026

Copy link
Copy Markdown
Author

To clarify the scope: the reproducer returns the defaults object from native Swift to Kotlin. Ordinary native reads and writes use different accessors.

The regression calls this native function from the transpiled test:

public func getStandardUserDefaults() -> Any {
    UserDefaults.standard
}

With only UserDefaultsAccess.userDefaults reverted to its original internal visibility, it crashes on Android 14 ARM64. Selected frames, demangled and abbreviated:

AndroidUserDefaults_Bridge.swift:30:
Fatal error: Unexpectedly found nil while unwrapping an Optional value
SIGTRAP

#02 Java_get_userDefaults_methodID (one-time initialization)
#08 UserDefaultsAccess.userDefaults.getter
#09 AndroidUserDefaults.toJavaObject(options:)
#11 AnyBridging.toJavaObject(_:options:)
#12 SkipAndroidBridgeSamplesKt_Swift_getStandardUserDefaults_5
#19 SkipAndroidBridgeSamplesKt.getStandardUserDefaults
#24 SkipAndroidBridgeSamplesTests.testUserDefaultsReturnedFromNativeSwift

The failing generated lookup is:

Java_class.getMethodID(
    name: "getUserDefaults",
    sig: "()Lskip/foundation/UserDefaults;"
)!

AndroidUserDefaults.toJavaObject accesses that getter to retrieve the underlying Kotlin object. The internal Kotlin getter has a mangled name, while this lookup expects the public name.

Restoring public let userDefaults makes the regression and all 20 Android bridge/sample tests pass. This stack is from the sample regression included in this PR.

@shial4

shial4 commented Oct 24, 2026

Copy link
Copy Markdown
Author

Following up on my reply, here is the evidence from the saved local Android regression runs on October 24.

The comparison used the regression in this PR against upstream base 19e85888165f952421b11d90f11793b33d2d75a4, on Android 14/API 34 ARM64. Only the property visibility fix was reverted for the failing run, then restored for the passing run; the sample function and test stayed in place. Both runs regenerated the bridge with the same local Skip 1.9.19 build, which included the separate native-test-harness changes discussed in skiptools/skipstone#311.

With the original internal property, this command from the generated Gradle root runs the reproducer:

gradle :SkipAndroidBridgeSamples:connectedDebugAndroidTest \
  '-Pandroid.testInstrumentationRunnerArguments.class=skip.android.bridge.samples.SkipAndroidBridgeSamplesTests#testUserDefaultsReturnedFromNativeSwift$SkipAndroidBridgeSamples'

Its result was:

Test run failed to complete. Instrumentation run failed due to Process crashed.
BUILD FAILED in 28s
Original crash-log excerpt, including the mangled Swift symbols

These are selected original lines from that sample test process. Intermediate framework frames are omitted.

10-24 21:46:18.815   807   834 I TestRunner: started: testUserDefaultsReturnedFromNativeSwift$SkipAndroidBridgeSamples(skip.android.bridge.samples.SkipAndroidBridgeSamplesTests)
10-24 21:46:19.371   839   839 F DEBUG   : ABI: 'arm64'
10-24 21:46:19.371   839   839 F DEBUG   : Cmdline: skip.android.bridge.samples.module.test
10-24 21:46:19.371   839   839 F DEBUG   : signal 5 (SIGTRAP), code 1 (TRAP_BRKPT), fault addr 0x00000075cc40b6fc
10-24 21:46:19.371   839   839 F DEBUG   : Abort message: 'SkipAndroidBridge/AndroidUserDefaults_Bridge.swift:30: Fatal error: Unexpectedly found nil while unwrapping an Optional value'
10-24 21:46:19.371   839   839 F DEBUG   :       #02 pc 00000000000e4bfc  /data/app/~~CIFFrCoqre9MkeBnQUs48A==/skip.android.bridge.samples.module.test-1XoMGUkgoOEU2jTGRYVurw==/base.apk (offset 0x726c000) ($s17SkipAndroidBridge18UserDefaultsAccessC013Java_get_userE9_methodID33_F6789CB312E8735073B27F36969322B0LL_WZ+292) (BuildId: b03728576e85236b84d2b343138468bb9ea94549)
10-24 21:46:19.371   839   839 F DEBUG   :       #08 pc 00000000000ea270  /data/app/~~CIFFrCoqre9MkeBnQUs48A==/skip.android.bridge.samples.module.test-1XoMGUkgoOEU2jTGRYVurw==/base.apk (offset 0x726c000) ($s17SkipAndroidBridge18UserDefaultsAccessC04userE00aC016AnyDynamicObjectCvg+104) (BuildId: b03728576e85236b84d2b343138468bb9ea94549)
10-24 21:46:19.371   839   839 F DEBUG   :       #09 pc 00000000000e4858  /data/app/~~CIFFrCoqre9MkeBnQUs48A==/skip.android.bridge.samples.module.test-1XoMGUkgoOEU2jTGRYVurw==/base.apk (offset 0x726c000) ($s17SkipAndroidBridge0B12UserDefaultsC12toJavaObject7optionsSvSg8SwiftJNI19JConvertibleOptionsV_tF+64) (BuildId: b03728576e85236b84d2b343138468bb9ea94549)
10-24 21:46:19.371   839   839 F DEBUG   :       #11 pc 0000000000057ad4  /data/app/~~CIFFrCoqre9MkeBnQUs48A==/skip.android.bridge.samples.module.test-1XoMGUkgoOEU2jTGRYVurw==/base.apk (offset 0x357c000) ($s10SkipBridge11AnyBridgingV12toJavaObject_7optionsSvSgypSg_8SwiftJNI19JConvertibleOptionsVtFZ+568) (BuildId: e530bc5a002ee349d9d65b8db17e239fa1a1d945)
10-24 21:46:19.371   839   839 F DEBUG   :       #12 pc 00000000000fe1cc  /data/app/~~CIFFrCoqre9MkeBnQUs48A==/skip.android.bridge.samples.module.test-1XoMGUkgoOEU2jTGRYVurw==/base.apk (offset 0x726c000) ($s24SkipAndroidBridgeSamples0abcD34Kt_Swift_getStandardUserDefaults_5ySvSpySPySo18JNINativeInterfaceVGSgG_SvtF+152) (BuildId: b03728576e85236b84d2b343138468bb9ea94549)
10-24 21:46:19.371   839   839 F DEBUG   :       #19 pc 0000000000001850  /data/app/~~CIFFrCoqre9MkeBnQUs48A==/skip.android.bridge.samples.module.test-1XoMGUkgoOEU2jTGRYVurw==/base.apk (skip.android.bridge.samples.SkipAndroidBridgeSamplesKt.getStandardUserDefaults+0)
10-24 21:46:19.371   839   839 F DEBUG   :       #24 pc 000000000000299c  /data/app/~~CIFFrCoqre9MkeBnQUs48A==/skip.android.bridge.samples.module.test-1XoMGUkgoOEU2jTGRYVurw==/base.apk (skip.android.bridge.samples.SkipAndroidBridgeSamplesTests.testUserDefaultsReturnedFromNativeSwift$SkipAndroidBridgeSamples+0)

After restoring public let userDefaults and regenerating/rebuilding, the full unfiltered :SkipAndroidBridgeSamples:connectedDebugAndroidTest task completed:

Starting 20 tests on emulator-34-medium_phone(AVD) - 14
Finished 20 tests on emulator-34-medium_phone(AVD) - 14
BUILD SUCCESSFUL in 15s

The connected-test XML confirms the totals and the new test case:

tests=20, failures=0, errors=0, skipped=0
<testcase name="testUserDefaultsReturnedFromNativeSwift$SkipAndroidBridgeSamples" classname="skip.android.bridge.samples.SkipAndroidBridgeSamplesTests" time="0.001" />

The logs directly establish the crash in the getter-method lookup and the successful run after restoring public visibility. The getter-name mismatch is the diagnosis from the generated JNI lookup and Kotlin visibility behavior; the log does not itself print the mangled getter name.

@shial4
shial4 marked this pull request as ready for review October 24, 2026 21:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants