Skip to content

JNI: escaping closures taking jextracted types; @Sendable/@MainActor closure types - #2

Open
piercifani wants to merge 2 commits into
jni/01-main-actor-thunksfrom
jni/02-closures-jextracted-types
Open

piercifani wants to merge 2 commits into
jni/01-main-actor-thunksfrom
jni/02-closures-jextracted-types

Conversation

@piercifani

@piercifani piercifani commented Nov 3, 2026 •

Copy link
Copy Markdown

Stack 2/21 · base: jni/01-main-actor-thunks · previous: #11 · next: #3

Problem

In JNI mode:

  1. Escaping closures whose parameters are jextracted types (structs, classes, enums) produced Swift that didn't compile. The closure's @JavaInterface wrapper declared the Swift type (not a JavaValue). The statements that wrap the value in its Java class were rendered into a printer that was then discarded, leaving _0Class / _0Pointer undefined.
  2. Function-type attributes were matched by substring. Only @escaping was understood; @Sendable and @MainActor were dropped, and without @escaping any attribute made the type unsupported. A stored property of type @MainActor @Sendable (Item) -> Void was skipped.
  3. The closure wrapper keeps the JNIEnv of the thread that created it, so a @Sendable closure called from another thread used the wrong environment.
  4. A closure-typed property's setter went through the non-escaping lowering, so the stored closure captured the call's JNIEnv and a local reference that are invalid once the call returns.

Changes

  • Parse function-type attributes one by one: @escaping, @Sendable, @MainActor, @convention(c|swift). Other attributes are still tolerated next to @escaping, as before.
  • In the closure's @JavaInterface, declare jextracted parameters as their Java class (JavaItem?, and JavaOptional<JavaItem>? for optionals), and emit the argument conversions inside the closure body.
  • Without Java callbacks (when wrap-java doesn't run), print a minimal @JavaClass wrapper with wrapMemoryAddressUnsafe for each such type, once per module. With callbacks on, wrap-java's classes are used.
  • For @Sendable closures, re-wrap the Java object with the calling thread's JNIEnv (JavaVirtualMachine.shared().environment()) on each call.
  • Treat a closure property's setter newValue as escaping.
  • Docs: escaping closures with jextracted types; @Sendable / @MainActor closure types.

Why Skip needs it

Skip Fuse view models take a Callbacks struct of @MainActor @Sendable closures whose parameters are app structs. In a production Skip Fuse app (16 Skip modules), 849 of 1,351 bridged closures have non-primitive parameter or result types, and 419 are main-actor closures.

Kotlin in this PR

ClosuresTest.kt with Closures.swift: Kotlin lambdas passed as escaping closures taking a struct, as a closure with an optional struct parameter, and as a @Sendable closure that Swift calls on another thread. The test also builds a Callbacks struct of @MainActor @Sendable closures (macOS only).

Testing

  • Kotlin sample: ./gradlew :Samples:SwiftJavaExtractJNIKotlinSampleApp:test passes at this point in the stack.
  • New JNIEscapingClosureJExtractedTypesTests (7 tests); all 7 fail on the base branch and pass with this change.
  • Full swift test --disable-experimental-prebuilts --disable-sandbox passes.
  • JNI sample: two new fixtures (deliverStruct, deliverOptionalStruct) with Java tests; ./gradlew :Samples:SwiftJavaExtractJNISampleApp:test passes 275/275. The sample builds with Java callbacks enabled, so that mode is covered.
  • A standalone Swift 6 package, without Java callbacks, passes Kotlin tests on the JVM: a struct-taking escaping closure, a closure with an optional struct parameter, a @Sendable closure invoked from a background thread, and a Callbacks struct of @MainActor @Sendable closures.
  • swift format lint --strict on changed files; git diff --check.

Not in this PR

  • Optional closure properties, non-escaping closures taking structs, closures as return values, and async closures are still dropped (silently).
  • With Java callbacks on, wrap-java emits an unescaped init holder type for initializers that take closures (JavaFoo.init.onOpen), which doesn't compile. That's a separate wrap-java fix.

🤖 Generated with Claude Code

piercifani and others added 2 commits November 3, 2026 16:53
…mainactor closure types

Escaping closures whose parameters were jextracted types produced Swift that
didn't compile: the @javainterface wrapper declared the Swift type (which
isn't a JavaValue), and the statements wrapping the value in its Java class
were rendered into a discarded printer, leaving `_0Class`/`_0Pointer`
undefined. Closure types were also matched by substring, so only `@escaping`
was understood and any other attribute without it made the type unsupported.

- Parse function-type attributes individually: `@escaping`, `@Sendable`,
  `@MainActor`, `@convention(c|swift)`.
- Declare jextracted parameters in the closure's @javainterface as their Java
  class (`JavaItem?`, `JavaOptional<JavaItem>?` for optionals) and emit the
  argument conversions inside the closure body.
- Without Java callbacks (no wrap-java run), print a minimal @javaclass wrapper
  with `wrapMemoryAddressUnsafe` for each such type, once per module.
- A @sendable closure can run on any thread, so re-wrap the Java object with
  the calling thread's JNIEnv on each call instead of the creating thread's.
- Treat a closure property's setter `newValue` as escaping, since stored
  closures always are; it previously captured the call's JNIEnv and local ref.

Adds generator tests (all fail without this change), JNI sample fixtures with
Java tests, and documentation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closures with jextracted and optional parameters, a @sendable closure invoked on
another thread, and a Callbacks struct of @mainactor @sendable closures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant