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

enforce same thread on getState() #34

Merged
merged 11 commits into from
Dec 16, 2019
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
## Unreleased

## [0.3.0] - 2019-12-16

### Added
- thread enforcement

## [0.2.9] - 2019-11-23

### Changed
- update Kotlin to 1.3.60

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,15 @@ kotlin {
sourceSets {
commonMain { // <--- name may vary on your project
dependencies {
implementation "org.reduxkotlin:redux-kotlin:0.2.9"
implementation "org.reduxkotlin:redux-kotlin:0.3.0"
}
}
}
```

For JVM only:
```
implementation "org.reduxkotlin:redux-kotlin-jvm:0.2.9"
implementation "org.reduxkotlin:redux-kotlin-jvm:0.3.0"
```

Usage is very similar to JS Redux and those docs will be useful https://redux.js.org/. These docs are not an intro to Redux, and just documentation on Kotlin specific bits. For more info on Redux in general, check out https://redux.js.org/.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import android.os.Handler
import androidx.appcompat.app.AppCompatActivity
import kotlinx.android.synthetic.main.activity_main.*
import org.reduxkotlin.StoreSubscription
import org.reduxkotlin.combineReducers
import org.reduxkotlin.createStore
import org.reduxkotlin.examples.counter.Decrement
import org.reduxkotlin.examples.counter.Increment
Expand Down
20 changes: 19 additions & 1 deletion lib/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ apply plugin: 'kotlin-multiplatform'
archivesBaseName = 'redux-kotlin'

group 'org.reduxkotlin'
version '0.2.9'
version '0.3.0'

kotlin {
jvm()
Expand Down Expand Up @@ -87,6 +87,24 @@ kotlin {
implementation kotlin("stdlib-js")
}
}
winMain {
kotlin.srcDir('src/fallbackMain')
}
wasmMain {
kotlin.srcDir('src/fallbackMain')
}
linArm32Main {
kotlin.srcDir('src/fallbackMain')
}
linMips32Main {
kotlin.srcDir('src/fallbackMain')
}
linMipsel32Main {
kotlin.srcDir('src/fallbackMain')
}
lin64Main {
kotlin.srcDir('src/fallbackMain')
}

iosSimMain.dependsOn iosMain
iosSimTest.dependsOn iosTest
Expand Down
16 changes: 15 additions & 1 deletion lib/src/commonMain/kotlin/org/reduxkotlin/CreateStore.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.reduxkotlin

import org.reduxkotlin.utils.getThreadName
import org.reduxkotlin.utils.isPlainObject

/**
Expand Down Expand Up @@ -44,6 +45,15 @@ fun <State> createStore(
var currentListeners = mutableListOf<() -> Unit>()
var nextListeners = currentListeners
var isDispatching = false
val storeThreadName = getThreadName()
fun isSameThread() = getThreadName() == storeThreadName
fun checkSameThread() = check(isSameThread()) {
"""You may not call the store from a thread other than the thread on which it was created.
|This includes: getState(), dispatch(), subscribe(), and replaceReducer()
|This store was created on: '$storeThreadName' and current
|thread is '${getThreadName()}'
""".trimMargin()
}

/**
* This makes a shallow copy of currentListeners so we can use
Expand All @@ -64,6 +74,7 @@ fun <State> createStore(
* @returns {S} The current state tree of your application.
*/
fun getState(): State {
checkSameThread()
check(!isDispatching) {
"""|You may not call store.getState() while the reducer is executing.
|The reducer has already received the state as an argument.
Expand Down Expand Up @@ -100,6 +111,7 @@ fun <State> createStore(
* @returns {StoreSubscription} A fun to remove this change listener.
*/
fun subscribe(listener: StoreSubscriber): StoreSubscription {
checkSameThread()
check(!isDispatching) {
"""|You may not call store.subscribe() while the reducer is executing.
|If you would like to be notified after the store has been updated,
Expand Down Expand Up @@ -159,6 +171,7 @@ fun <State> createStore(
* return something else (for example, a Promise you can await).
*/
fun dispatch(action: Any): Any {
checkSameThread()
require(isPlainObject(action)) {
"""Actions must be plain objects. Use custom middleware for async
|actions.""".trimMargin()
Expand Down Expand Up @@ -193,6 +206,7 @@ fun <State> createStore(
* @returns {void}
*/
fun replaceReducer(nextReducer: Reducer<State>) {
checkSameThread()
currentReducer = nextReducer

// This action has a similar effect to ActionTypes.INIT.
Expand All @@ -217,7 +231,7 @@ fun <State> createStore(
// the initial state tree.
dispatch(ActionTypes.INIT)

return object: Store<State> {
return object : Store<State> {
override val getState = ::getState
override var dispatch: Dispatcher = ::dispatch
override val subscribe = ::subscribe
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.reduxkotlin.utils

const val UNKNOWN_THREAD_NAME = "UNKNOWN_THREAD_NAME"

/**
* Returns the name of the current thread.
*/
expect fun getThreadName(): String
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.reduxkotlin.utils

/**
* Fallback for platforms that have not been implemented yet.
* This will allow usage of ReduxKotlin, but not allow
* thread enforcement.
* Linux, Win, WASM
*/
actual fun getThreadName(): String = UNKNOWN_THREAD_NAME
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package org.reduxkotlin.utils
import platform.Foundation.NSThread.Companion.currentThread

actual fun getThreadName(): String = currentThread.name ?: UNKNOWN_THREAD_NAME
3 changes: 3 additions & 0 deletions lib/src/jsMain/kotlin/org/reduxkotlin/utils/ThreadUtil.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package org.reduxkotlin.utils

actual fun getThreadName() = "main"
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package org.reduxkotlin.utils

actual fun getThreadName(): String = Thread.currentThread().name
58 changes: 58 additions & 0 deletions lib/src/jvmTest/kotlin/org/reduxkotlin/util/ThreadUtilSpec.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package org.reduxkotlin.util

import org.reduxkotlin.*
import org.spekframework.spek2.Spek
import org.spekframework.spek2.style.specification.describe
import java.util.concurrent.CountDownLatch
import kotlin.IllegalStateException
import kotlin.test.assertNotNull
import kotlin.test.assertNull

object ThreadUtilSpec : Spek({
describe("createStore") {
val store = createStore(
todos, TestState(
listOf(
Todo(
id = "1",
text = "Hello"
)
)
)
)

it("ensure same thread on getState") {
ensureSameThread { store.getState() }
}
it("ensure same thread on dispatch") {
ensureSameThread { store.dispatch(Any()) }
}
it("ensure same thread on replaceReducer") {
ensureSameThread { store.replaceReducer { state, action -> state } }
}
it("ensure same thread on subscribe") {
ensureSameThread { store.subscribe { } }
}
}
})

private fun ensureSameThread(getState: () -> Any) {
val latch = CountDownLatch(1)
var exception: java.lang.IllegalStateException? = null
var state: Any? = null

val newThread = Thread {
state = getState()
}

newThread.setUncaughtExceptionHandler { thread, throwable ->
exception = throwable as IllegalStateException
latch.countDown()
}
newThread.start()

latch.await()

assertNotNull(exception)
assertNull(state)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.reduxkotlin.utils

import platform.Foundation.NSThread.Companion.currentThread

actual fun getThreadName(): String = currentThread.name ?: UNKNOWN_THREAD_NAME
3 changes: 2 additions & 1 deletion website/docs/api/Store.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ The only way to change the state inside it is to dispatch an [action](../Glossar

A store is just a plain object that contains your current state and a 4 functions.
To create it, pass your root [reducing function](../Glossary.md#reducer) to
[`createStore`](createStore.md).
[`createStore`](createStore.md). The store has [same thread enforcement](../introduction/threading), meaning
its methods must be called from the same thread where the store was created.

> ##### A Note for Flux Users
>
Expand Down
4 changes: 2 additions & 2 deletions website/docs/introduction/Ecosystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ implement features and solve problems in your application.

ReduxKotlin ecosystem is currently very small. This list will be updated as needed.

For inspiration or examples, the JS ecosystem is quite rich and (can be explored
here)[https://redux.js.org/introduction/ecosystem].
For inspiration or examples, the JS ecosystem is quite rich and [can be explored
here](https://redux.js.org/introduction/ecosystem).

**[Presenter-middleware](https://github.com/reduxkotlin/presenter-middleware)**
A middleware for writing concise UI binding code and no-fuss lifecycle/subscription management.
4 changes: 2 additions & 2 deletions website/docs/introduction/GettingStarted.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ kotlin {
sourceSets {
commonMain {
dependencies {
implementation "org.reduxkotlin:redux-kotlin:0.2.9"
implementation "org.reduxkotlin:redux-kotlin:0.3.0"
}
}
}
Expand All @@ -47,7 +47,7 @@ __For single platform project (i.e. just Android):__

```groovy
dependencies {
implementation "org.reduxkotlin:redux-kotlin:0.2.9"
implementation "org.reduxkotlin:redux-kotlin:0.3.0"
}
```

Expand Down
2 changes: 1 addition & 1 deletion website/docs/introduction/Motivation.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ sharing code between Android, iOS, web.

Redux as a pattern for sharing code between platforms looks promising, especially in the future with
Jetpack Compose and SwiftUI. For an example of shared code in a SwiftUI app look at the
(MovieSwiftUI-Kotlin)[https://github.com/reduxkotlin/MovieSwiftUI-Kotlin] example.
[MovieSwiftUI-Kotlin](https://github.com/reduxkotlin/MovieSwiftUI-Kotlin) example.

1 change: 1 addition & 0 deletions website/docs/introduction/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
- [Motivation](Motivation.md)
- [Core Concepts](CoreConcepts.md)
- [Three Principles](ThreePrinciples.md)
- [Threading](Threading.md)
- [Learning Resources](LearningResources.md)
- [Ecosystem](Ecosystem.md)
- [Examples](Examples.md)
30 changes: 30 additions & 0 deletions website/docs/introduction/Threading.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
id: threading
title: Redux on Multi-threaded Platforms
sidebar_label: Threading
hide_title: true
---

# Redux on Multi-threaded Platforms

Redux in multi-threaded environments brings additional concerns that are not present in redux
for Javascript. Javascript is single threaded, so Redux.js did not have to address the issue.
Android, iOS, and native do have threading, and as such attention must be paid to which threads interact with the store.

As of ReduxKotlin 0.3.0 there is `same thread enforcement` for the [getState](../api/store#getstate-_or_-state-property), [dispatch](../api/store#dispatchaction-any-any), [replaceReducer](../api/store#replacereducernextreducer-reducer-state-unit),
and [subscribe](../api/store#subscribelistener-storesubscriber) functions on the store. This means these methods must be called from the same thread where
the store was created. An `IllegalStateException` will be thrown if one of these are called from a
different thread.

If this `same thread enforcement` was not in place invalid states and race conditions could happen.
For example if `getState` was called at the same time as a dispatch, the state would represent a past
state. Or 2 actions dispatched concurrently could cause an invalid state.

Note that this is __SAME__ thread enforcement - not __MAIN__ thread enforcement. ReduxKotlin does not
force you to use the main thread, although you can if you'd like. Most mobile applications do redux on the main
thread, however it could be moved to a background thread. Using a background thread could be desirable
if the reducers & middleware processing produce UI effects such as dropped frames.


Currently `same thread enforcement` is implemented for JVM, iOS, & macOS. The other platforms
have do not have the enforcement in place yet.
6 changes: 5 additions & 1 deletion website/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"localized-strings": {
"next": "Next",
"previous": "Previous",
"tagline": "A Predictable State Container for Kotlin Apps",
"tagline": "Redux in Kotlin supporting multiplatform - Android, iOS, Web, Native",
"docs": {
"advanced/async-actions": {
"title": "Async Actions",
Expand Down Expand Up @@ -116,6 +116,10 @@
"introduction/README": {
"title": "introduction/README"
},
"introduction/threading": {
"title": "Redux on Multi-threaded Platforms",
"sidebar_label": "Threading"
},
"introduction/three-principles": {
"title": "Three Principles",
"sidebar_label": "Three Principles"
Expand Down
1 change: 1 addition & 0 deletions website/sidebars.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"introduction/motivation",
"introduction/core-concepts",
"introduction/three-principles",
"introduction/threading",
"introduction/learning-resources",
"introduction/ecosystem",
"introduction/examples"
Expand Down
Loading