Skip to content

JNI: cancelling an async function's future cancels the Swift task - #10

Open
piercifani wants to merge 2 commits into
jni/07-class-inheritancefrom
jni/08-async-cancellation
Open

piercifani wants to merge 2 commits into
jni/07-class-inheritancefrom
jni/08-async-cancellation

Conversation

@piercifani

@piercifani piercifani commented Nov 3, 2026 •

Copy link
Copy Markdown

Stack 8/21 · base: jni/07-class-inheritance · previous: #8 · next: #13

Problem

In JNI mode an async Swift function returns a CompletableFuture, but cancelling it didn't stop the Swift work:

  • The thunk starts a Task and keeps it only in a local variable, so nothing can cancel it.
  • The Java method returns future$.thenApply(...), a derived future. Cancelling a derived future doesn't propagate to its source, so even the future Swift completes never saw the cancellation.

So when a caller gives up (in Kotlin, a coroutine cancelled because a screen went away), the Swift task keeps running to completion.

Changes

  • SwiftKitCore gets CompletableFutureCancellation:
    • forwardCancellation(result, source) cancels source when the returned future result is cancelled.
    • cancelTaskWhenCancelled(future, task) registers whenComplete on future. If it ended cancelled, it calls the native $cancelTask; in every case it calls $releaseTask, so the handle is released exactly once.
  • SwiftJavaRuntimeSupport gets _cancelTaskWhenFutureIsCancelled(_:task:environment:), which retains a handle to the task, plus the two native entry points.
  • Generated code, completableFuture mode only:
    • The Java method returns CompletableFutureCancellation.forwardCancellation(future$.thenApply(...), future$).
    • The Swift thunk registers the task after creating it. It uses the call's own result_future reference, which stays valid for the whole call even if a Task.immediate task has already finished and deleted its global reference.
    • The task handle never changes after creation, so no lock is needed.
    • legacyFuture mode is unchanged.

The Swift task sees an ordinary Task cancellation (Task.isCancelled, CancellationError from Task.sleep). Completing an already-cancelled future afterwards is a no-op.

Kotlin in this PR

CancellationTest.kt with Cancellation.swift: withTimeoutOrNull(100) { download.run().await() }. When the timeout cancels the coroutine, kotlinx-coroutines' await() cancels the future, and with this PR the Swift task sees the cancellation.

Why Skip needs it

Skip bridges async Swift functions to Kotlin suspend functions, and Compose cancels coroutines when screens leave composition. A production Skip Fuse app (16 Skip modules) exports 103 async functions; without this their work outlives the screen that started it.

Testing

  • Kotlin sample: ./gradlew :Samples:SwiftJavaExtractJNIKotlinSampleApp:test passes at this point in the stack.
  • New JNIAsyncCancellationTests: forwarding in the Java method, task registration in the Swift thunk, and legacyFuture mode unchanged. JNIAsyncTests and JNIActorTests expectations updated for the new output.
  • JNI sample: CancellableWork with a Java test that future.cancel(true) cancels the Swift task.
  • Full swift test --disable-experimental-prebuilts --disable-sandbox and ./gradlew :Samples:SwiftJavaExtractJNISampleApp:test pass.
  • swift format lint --strict on changed files; git diff --check.

Not in this PR

  • legacyFuture mode (SimpleCompletableFuture) doesn't propagate cancellation.
  • Cancellation of async closures and AsyncStreams.

🤖 Generated with Claude Code

piercifani and others added 2 commits November 3, 2026 17:35
The thunk for an exported `async` function kept its Task only in a local, and
the Java method returned a derived future (`thenApply`), whose cancellation
doesn't reach its source. Cancelling the returned future left the Swift work
running.

- SwiftKitCore's `CompletableFutureCancellation` forwards cancellation from the
  returned future to the source future, and cancels/releases a Swift task handle
  when the source future completes.
- SwiftJavaRuntimeSupport's `_cancelTaskWhenFutureIsCancelled` registers the task
  with the future the native call received.
- In `completableFuture` mode the generated Java forwards cancellation and the
  Swift thunk registers the task. `legacyFuture` mode is unchanged.

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