Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] JS Support #43

Draft
wants to merge 10 commits into
base: master
Choose a base branch
from
3 changes: 3 additions & 0 deletions buildSrc/src/main/kotlin/Dependencies.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,8 @@ object Dependencies {

object Kotlinx {
const val coroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0-native-mt"
const val coroutinesTest = "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0-native-mt"

const val atomicfu = "org.jetbrains.kotlinx:atomicfu:0.17.1"
}
}
19 changes: 17 additions & 2 deletions kmp-nativecoroutines-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,23 @@ kotlin {
val commonTest by getting {
dependencies {
implementation(kotlin("test"))

implementation(Dependencies.Kotlinx.coroutinesTest)
implementation(Dependencies.Kotlinx.atomicfu)
}
}
val appleMain by creating {
val supportedTargetMain by creating {
dependsOn(commonMain)
}
val appleTest by creating {
val supportedTargetTest by creating {
dependsOn(commonTest)
}
val appleMain by creating {
dependsOn(supportedTargetMain)
}
val appleTest by creating {
dependsOn(supportedTargetTest)
}
listOf(
macosX64, macosArm64,
iosArm64, iosX64, iosSimulatorArm64,
Expand All @@ -56,5 +65,11 @@ kotlin {
dependsOn(appleTest)
}
}
val jsMain by getting {
dependsOn(supportedTargetMain)
}
val jsTest by getting {
dependsOn(supportedTargetTest)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.rickclephas.kmp.nativecoroutines

import kotlin.native.concurrent.freeze

actual fun <T> T.freeze(): T = this.freeze()
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import platform.Foundation.NSError
import platform.Foundation.NSLocalizedDescriptionKey
import kotlin.native.concurrent.freeze

actual typealias PlatformError = NSError

internal actual fun Throwable.asPlatformError(): PlatformError = this.asNSError()

actual val PlatformError.kotlinCause
get() = this.userInfo["KotlinException"] as? Throwable

/**
* Converts a [Throwable] to a [NSError].
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package com.rickclephas.kmp.nativecoroutines
import kotlin.native.concurrent.isFrozen
import kotlin.test.*

class NativeCallbackTests {
class AppleNativeCallbackTests {

@Test
fun `ensure frozen`() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class NativeCancellableTests {
class AppleNativeCancellableTests {

@Test
fun `ensure frozen`() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package com.rickclephas.kmp.nativecoroutines

import kotlinx.atomicfu.atomic
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.flow
import kotlin.native.concurrent.AtomicInt
import kotlin.native.concurrent.isFrozen
import kotlin.test.*

class NativeFlowTests {
class AppleNativeFlowTests {

@Test
fun `ensure frozen`() {
Expand All @@ -23,10 +23,10 @@ class NativeFlowTests {
val flow = flow<RandomValue> { }
val job = Job()
val nativeFlow = flow.asNativeFlow(CoroutineScope(job))
val completionCount = AtomicInt(0)
val completionCount = atomic(0)
nativeFlow({ _, _ -> }, { error, _ ->
assertNull(error, "Flow should complete without an error")
completionCount.increment()
completionCount.incrementAndGet()
})
job.children.forEach { it.join() } // Waits for the collection to complete
assertEquals(1, completionCount.value, "Completion callback should be called once")
Expand All @@ -38,12 +38,12 @@ class NativeFlowTests {
val flow = flow<RandomValue> { throw exception }
val job = Job()
val nativeFlow = flow.asNativeFlow(CoroutineScope(job))
val completionCount = AtomicInt(0)
val completionCount = atomic(0)
nativeFlow({ _, _ -> }, { error, _ ->
assertNotNull(error, "Flow should complete with an error")
val kotlinException = error.userInfo["KotlinException"]
val kotlinException = error.kotlinCause
assertSame(exception, kotlinException, "Kotlin exception should be the same exception")
completionCount.increment()
completionCount.incrementAndGet()
})
job.children.forEach { it.join() } // Waits for the collection to complete
assertEquals(1, completionCount.value, "Completion callback should be called once")
Expand All @@ -55,10 +55,10 @@ class NativeFlowTests {
val flow = flow { values.forEach { emit(it) } }
val job = Job()
val nativeFlow = flow.asNativeFlow(CoroutineScope(job))
val receivedValueCount = AtomicInt(0)
val receivedValueCount = atomic(0)
nativeFlow({ value, _ ->
assertSame(values[receivedValueCount.value], value, "Received incorrect value")
receivedValueCount.increment()
receivedValueCount.incrementAndGet()
}, { _, _ -> })
job.children.forEach { it.join() } // Waits for the collection to complete
assertEquals(values.size, receivedValueCount.value, "Item callback should be called for every value")
Expand All @@ -69,12 +69,12 @@ class NativeFlowTests {
val flow = MutableSharedFlow<RandomValue>()
val job = Job()
val nativeFlow = flow.asNativeFlow(CoroutineScope(job))
val completionCount = AtomicInt(0)
val completionCount = atomic(0)
val cancel = nativeFlow({ _, _ -> }, { error, _ ->
assertNotNull(error, "Flow should complete with an error")
val exception = error.userInfo["KotlinException"]
val exception = error.kotlinCause
assertIs<CancellationException>(exception, "Error should contain CancellationException")
completionCount.increment()
completionCount.incrementAndGet()
})
delay(100) // Gives the collection some time to start
cancel()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
package com.rickclephas.kmp.nativecoroutines

import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlin.coroutines.cancellation.CancellationException
import kotlin.native.concurrent.AtomicInt
import kotlin.native.concurrent.isFrozen
import kotlin.test.*

class NativeSuspendTests {
class AppleNativeSuspendTests {

private suspend fun delayAndReturn(delay: Long, value: RandomValue): RandomValue {
delay(delay)
Expand All @@ -35,13 +35,13 @@ class NativeSuspendTests {
val value = RandomValue()
val job = Job()
val nativeSuspend = nativeSuspend(CoroutineScope(job)) { delayAndReturn(100, value) }
val receivedResultCount = AtomicInt(0)
val receivedErrorCount = AtomicInt(0)
val receivedResultCount = atomic(0)
val receivedErrorCount = atomic(0)
nativeSuspend({ receivedValue, _ ->
assertSame(value, receivedValue, "Received incorrect value")
receivedResultCount.increment()
receivedResultCount.incrementAndGet()
}, { _, _ ->
receivedErrorCount.increment()
receivedErrorCount.incrementAndGet()
})
job.children.forEach { it.join() } // Waits for the function to complete
assertEquals(1, receivedResultCount.value, "Result callback should be called once")
Expand All @@ -53,15 +53,15 @@ class NativeSuspendTests {
val exception = RandomException()
val job = Job()
val nativeSuspend = nativeSuspend(CoroutineScope(job)) { delayAndThrow(100, exception) }
val receivedResultCount = AtomicInt(0)
val receivedErrorCount = AtomicInt(0)
val receivedResultCount = atomic(0)
val receivedErrorCount = atomic(0)
nativeSuspend({ _, _ ->
receivedResultCount.increment()
receivedResultCount.incrementAndGet()
}, { error, _ ->
assertNotNull(error, "Function should complete with an error")
val kotlinException = error.userInfo["KotlinException"]
val kotlinException = error.kotlinCause
assertSame(exception, kotlinException, "Kotlin exception should be the same exception")
receivedErrorCount.increment()
receivedErrorCount.incrementAndGet()
})
job.children.forEach { it.join() } // Waits for the function to complete
assertEquals(1, receivedErrorCount.value, "Error callback should be called once")
Expand All @@ -72,15 +72,15 @@ class NativeSuspendTests {
fun `ensure function is cancelled`() = runBlocking {
val job = Job()
val nativeSuspend = nativeSuspend(CoroutineScope(job)) { delayAndReturn(5_000, RandomValue()) }
val receivedResultCount = AtomicInt(0)
val receivedErrorCount = AtomicInt(0)
val receivedResultCount = atomic(0)
val receivedErrorCount = atomic(0)
val cancel = nativeSuspend({ _, _ ->
receivedResultCount.increment()
receivedResultCount.incrementAndGet()
}, { error, _ ->
assertNotNull(error, "Function should complete with an error")
val exception = error.userInfo["KotlinException"]
val exception = error.kotlinCause
assertIs<CancellationException>(exception, "Error should contain CancellationException")
receivedErrorCount.increment()
receivedErrorCount.incrementAndGet()
})
delay(100) // Gives the function some time to start
cancel()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.rickclephas.kmp.nativecoroutines

/**
* Freezing is a no-op on JS
*/
internal actual fun <T> T.freeze() = this
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.rickclephas.kmp.nativecoroutines

actual typealias PlatformError = Throwable
actual fun Throwable.asPlatformError() = this
actual val PlatformError.kotlinCause: Throwable?
get() = this
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.rickclephas.kmp.nativecoroutines

internal expect fun <T> T.freeze(): T
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package com.rickclephas.kmp.nativecoroutines

import kotlin.native.concurrent.freeze

/**
* A callback with a single argument.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.rickclephas.kmp.nativecoroutines

import kotlinx.coroutines.Job
import kotlin.native.concurrent.freeze

/**
* A function that cancels the coroutines [Job].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,14 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import platform.Foundation.NSError
import kotlin.native.concurrent.freeze

/**
* A function that collects a [Flow] via callbacks.
*
* The function takes an `onItem` and `onComplete` callback
* and returns a cancellable that can be used to cancel the collection.
*/
typealias NativeFlow<T> = (onItem: NativeCallback<T>, onComplete: NativeCallback<NSError?>) -> NativeCancellable
typealias NativeFlow<T> = (onItem: NativeCallback<T>, onComplete: NativeCallback<PlatformError?>) -> NativeCancellable

/**
* Creates a [NativeFlow] for this [Flow].
Expand All @@ -25,7 +23,7 @@ typealias NativeFlow<T> = (onItem: NativeCallback<T>, onComplete: NativeCallback
*/
fun <T> Flow<T>.asNativeFlow(scope: CoroutineScope? = null): NativeFlow<T> {
val coroutineScope = scope ?: defaultCoroutineScope
return (collect@{ onItem: NativeCallback<T>, onComplete: NativeCallback<NSError?> ->
return (collect@{ onItem: NativeCallback<T>, onComplete: NativeCallback<PlatformError?> ->
val job = coroutineScope.launch {
try {
collect { onItem(it) }
Expand All @@ -35,13 +33,13 @@ fun <T> Flow<T>.asNativeFlow(scope: CoroutineScope? = null): NativeFlow<T> {
// this is required since the job could be cancelled before it is started
throw e
} catch (e: Throwable) {
onComplete(e.asNSError())
onComplete(e.asPlatformError())
}
}
job.invokeOnCompletion { cause ->
// Only handle CancellationExceptions, all other exceptions should be handled inside the job
if (cause !is CancellationException) return@invokeOnCompletion
onComplete(cause.asNSError())
onComplete(cause.asPlatformError())
}
return@collect job.asNativeCancellable()
}).freeze()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,14 @@ package com.rickclephas.kmp.nativecoroutines
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import platform.Foundation.NSError
import kotlin.native.concurrent.freeze

/**
* A function that awaits a suspend function via callbacks.
*
* The function takes an `onResult` and `onError` callback
* and returns a cancellable that can be used to cancel the suspend function.
*/
typealias NativeSuspend<T> = (onResult: NativeCallback<T>, onError: NativeCallback<NSError>) -> NativeCancellable
typealias NativeSuspend<T> = (onResult: NativeCallback<T>, onError: NativeCallback<PlatformError>) -> NativeCancellable

/**
* Creates a [NativeSuspend] for the provided suspend [block].
Expand All @@ -22,7 +20,7 @@ typealias NativeSuspend<T> = (onResult: NativeCallback<T>, onError: NativeCallba
*/
fun <T> nativeSuspend(scope: CoroutineScope? = null, block: suspend () -> T): NativeSuspend<T> {
val coroutineScope = scope ?: defaultCoroutineScope
return (collect@{ onResult: NativeCallback<T>, onError: NativeCallback<NSError> ->
return (collect@{ onResult: NativeCallback<T>, onError: NativeCallback<PlatformError> ->
val job = coroutineScope.launch {
try {
onResult(block())
Expand All @@ -31,13 +29,13 @@ fun <T> nativeSuspend(scope: CoroutineScope? = null, block: suspend () -> T): Na
// this is required since the job could be cancelled before it is started
throw e
} catch (e: Throwable) {
onError(e.asNSError())
onError(e.asPlatformError())
}
}
job.invokeOnCompletion { cause ->
// Only handle CancellationExceptions, all other exceptions should be handled inside the job
if (cause !is CancellationException) return@invokeOnCompletion
onError(cause.asNSError())
onError(cause.asPlatformError())
}
return@collect job.asNativeCancellable()
}).freeze()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.rickclephas.kmp.nativecoroutines

/**
* Represents an error in a way that the specific platform is able to handle
*/
expect class PlatformError

/**
* Converts a [Throwable] to a [PlatformError].
*/
internal expect fun Throwable.asPlatformError(): PlatformError

internal expect val PlatformError.kotlinCause: Throwable?
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.rickclephas.kmp.nativecoroutines

import kotlin.test.*

class NativeCallbackTests {
@Test
fun ensure_invoked() {
var invokeCount = 0
var receivedValue: RandomValue? = null
val callback: NativeCallback<RandomValue> = callback@{ value, unit ->
receivedValue = value
invokeCount++
// This isn't required in Kotlin but it is in Swift so we'll test it anyway
return@callback unit
}
val value = RandomValue()
callback(value)
assertEquals(1, invokeCount, "NativeCallback should have been invoked once")
assertSame(value, receivedValue, "Received value should be the same as the send value")
}
}
Loading