Skip to content

Propagate Swift task cancellation to bridged Kotlin async throws calls - #281

Open
piercifani wants to merge 1 commit into
skiptools:mainfrom
piercifani:async-cancellation
Open

piercifani wants to merge 1 commit into
skiptools:mainfrom
piercifani:async-cancellation

Conversation

@piercifani

@piercifani piercifani commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Cancelling a Swift task that awaits a bridged Kotlin async throws API never reached the Kotlin coroutine. The generated Swift awaited the callback_ function with a plain withCheckedThrowingContinuation, and the generated Kotlin ran the call in a fire-and-forget Task { … }, so a Kotlin implementation parked in suspendCancellableCoroutine (an OkHttp call, a channel receive) ran to completion or to its own timeout before the Swift await resumed. invokeOnCancellation never fired.

This change makes the two sides cooperate:

  • Kotlin (callback_ for throwing async functions and properties) runs the call under its own kotlinx.coroutines.Job via withContext(f_job) and returns that job. Cancelling it before the call starts makes withContext throw, so the callback still fires exactly once.
  • Swift awaits inside withTaskCancellationHandler, attaches the returned job through the new SkipBridge.BridgedJob helper (Add BridgedJob so cancelling a Swift task cancels the bridged Kotlin coroutine skip-bridge#120) and cancels it from the handler. A throwable delivered after that cancellation surfaces as CancellationError.
  • The JNI signature of the throwing callback_ functions changes from …)V to …)Lkotlinx/coroutines/Job;; both sides are generated together.

Non-throwing async APIs are unchanged: Swift cancellation can only surface through a throwing call, and withCheckedContinuation has no way to report it.

skip.lib.Task.cancel() is deliberately not used for this: it only flips the cooperative flag and runs Swift-style cancellation handlers, it does not cancel the underlying Deferred, so a coroutine suspended in suspendCancellableCoroutine would not observe it.

Testing

  • SkipSyntaxTests 867/867: the async throws golden cases in BridgeToSwiftTests and BridgeToKotlinTests updated to the new shape, and BridgeSendabilityTests.testGeneratedContinuationCompilesWithoutWarnings now compiles the whole generated function (with a BridgedJob stub) under -warnings-as-errors.
  • End to end, with a skip built from this branch driving code generation and Add BridgedJob so cancelling a Swift task cancels the bridged Kotlin coroutine skip-bridge#120 providing BridgedJob and the new sample test: skip-bridge's Robolectric run (SkipBridgeToSwiftSamplesTestsSupportTests.XCSkipTests) cancels a Swift task awaiting a Kotlin function parked in suspendCancellableCoroutine. With the released 1.9.11 generator that test fails ("The await did not resume within 5 s of the cancellation"); with this branch the await resumes with CancellationError and the coroutine's invokeOnCancellation fires, 70/70 pass. Cancelling before the call starts also resumes.

Depends on skiptools/skip-bridge#120 for BridgedJob.

🤖 Generated with Claude Code

The generated Swift for a bridged `async throws` API awaited the Kotlin
`callback_` function with a plain continuation, and the Kotlin side ran the
call in a fire-and-forget `Task`, so cancelling the Swift task never reached
the coroutine: a Kotlin implementation parked in `suspendCancellableCoroutine`
ran to completion (or to its own timeout) before the await resumed.

The Kotlin `callback_` function now runs the call under its own
`kotlinx.coroutines.Job` and returns it; the Swift side awaits inside
`withTaskCancellationHandler`, attaches the job through the new
`SkipBridge.BridgedJob` helper and cancels it when the task is cancelled. A
throwable delivered after that cancellation surfaces as `CancellationError`.
Cancelling the job before the call starts makes `withContext` throw, so the
callback still fires exactly once. Non-throwing async APIs are unchanged, as
Swift cancellation can only surface through a throwing call.

Requires skip-bridge with `BridgedJob`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@piercifani

Copy link
Copy Markdown
Contributor Author

The android-ci (macos-26-intel, 6.4) failure is the cross-repo dependency, not a regression: skip checkup --native builds the sample app with this generator against the released skip-bridge 0.17.3, which has no BridgedJob yet, so skip-ui's generated UserNotifications_Bridge.swift (two async throws APIs) fails with cannot find 'BridgedJob' in scope. The remaining macOS checkup jobs will fail the same way until skiptools/skip-bridge#120 lands and a skip-bridge release carries it; the ubuntu android-ci jobs and both build-plugin jobs pass.

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