Skip to content

Commit 89c0308

Browse files
committed
Implement Dispatchers.Main for Tao via MainDispatcherFactory SPI
Register TaoMainCoroutineDispatcher with loadPriority=100 to make Tao the canonical Dispatchers.Main when nucleus.decorated-window-tao is on the classpath. This routes all coroutine dispatches (including AndroidX Lifecycle, Compose, animations, Ktor timeouts) onto the Tao main thread. - Implement TaoMainCoroutineDispatcher extending MainCoroutineDispatcher + Delay - Register TaoMainDispatcherFactory with kotlinx-coroutines service loader - Eagerly capture Tao main thread in TaoApplication.run before native event loop starts, so MainDispatcherChecker resolves immediately - Add isDispatchNeeded check against captured thread to prevent EDT/main thread aliasing (strict single-thread semantics) - Include ProGuard keep rules and GraalVM reachability metadata
1 parent aa6ca7c commit 89c0308

6 files changed

Lines changed: 193 additions & 0 deletions

File tree

decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoApplication.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ object TaoApplication {
3434
"nucleus_tao native library is not available — supported targets: " +
3535
"macOS (arm64/x86_64), Windows (x64/aarch64), Linux (x64/aarch64)."
3636
}
37+
// Capture the Tao main thread eagerly, before the native event loop
38+
// takes over this thread. Required so `Dispatchers.Main` consumers
39+
// (notably AndroidX Lifecycle's synchronous `MainDispatcherChecker`)
40+
// can resolve the Tao thread immediately — a lazy capture at first
41+
// pump would race the very first `NavHost.setGraph` → `addObserver`
42+
// call on real apps.
43+
TaoMainDispatcher.taoMainThread = Thread.currentThread()
3744
onLaunched = block
3845
NativeTaoBridge.nativeRunBlocking(EventDispatcher)
3946
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package dev.nucleusframework.window.tao
2+
3+
import kotlinx.coroutines.CancellableContinuation
4+
import kotlinx.coroutines.Delay
5+
import kotlinx.coroutines.DisposableHandle
6+
import kotlinx.coroutines.InternalCoroutinesApi
7+
import kotlinx.coroutines.MainCoroutineDispatcher
8+
import kotlinx.coroutines.Runnable
9+
import java.util.concurrent.Executors
10+
import java.util.concurrent.ScheduledExecutorService
11+
import java.util.concurrent.TimeUnit
12+
import kotlin.coroutines.CoroutineContext
13+
14+
/**
15+
* `Dispatchers.Main` implementation for the Tao backend.
16+
*
17+
* Routes coroutine dispatches onto the Tao main thread via [TaoMainDispatcher],
18+
* which is drained on every `MAIN_EVENTS_CLEARED` native event tick. The
19+
* "main thread" predicate is intentionally strict: only the captured Tao main
20+
* thread is considered already-on-Main. The AWT EDT is **not** treated as a
21+
* Main alias — under the Tao backend the EDT may not be running at all, and
22+
* conflating the two leads to silent state splits and false negatives in
23+
* AndroidX Lifecycle's thread checker.
24+
*
25+
* Implements [Delay] (via a daemon [ScheduledExecutorService] that re-posts
26+
* resume callbacks through [dispatch]) so that `DefaultDelay` routes all
27+
* process-wide `delay()` calls back onto the Tao main thread instead of
28+
* silently parking them when the Tao runtime is the resolved
29+
* `Dispatchers.Main`. Without this, `Dispatchers.Main as Delay` would crash or
30+
* stall every `delay`/`withTimeout` in the process — including Compose
31+
* runtime, RepeatOnLifecycle, animation timers, and Ktor request timeouts.
32+
*
33+
* Discovered through [TaoMainDispatcherFactory] (`loadPriority = 100`) via the
34+
* standard `META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory`
35+
* service loader, mirroring `kotlinx-coroutines-swing`.
36+
*/
37+
@OptIn(InternalCoroutinesApi::class)
38+
internal sealed class TaoMainCoroutineDispatcher : MainCoroutineDispatcher(), Delay {
39+
override fun dispatch(
40+
context: CoroutineContext,
41+
block: Runnable,
42+
) {
43+
TaoMainDispatcher.dispatch(context, block)
44+
}
45+
46+
override fun isDispatchNeeded(context: CoroutineContext): Boolean {
47+
val taoMain = TaoMainDispatcher.taoMainThread
48+
return taoMain == null || Thread.currentThread() !== taoMain
49+
}
50+
51+
override fun scheduleResumeAfterDelay(
52+
timeMillis: Long,
53+
continuation: CancellableContinuation<Unit>,
54+
) {
55+
val future =
56+
DelayScheduler.schedule(
57+
{
58+
// Resume on the Tao main thread, undispatched, so the
59+
// resumed continuation runs synchronously in the next
60+
// pump tick rather than re-entering the dispatcher.
61+
dispatch(
62+
continuation.context,
63+
Runnable { with(continuation) { resumeUndispatched(Unit) } },
64+
)
65+
},
66+
timeMillis,
67+
TimeUnit.MILLISECONDS,
68+
)
69+
continuation.invokeOnCancellation { future.cancel(false) }
70+
}
71+
72+
override fun invokeOnTimeout(
73+
timeMillis: Long,
74+
block: Runnable,
75+
context: CoroutineContext,
76+
): DisposableHandle {
77+
val future =
78+
DelayScheduler.schedule(
79+
{ dispatch(context, block) },
80+
timeMillis,
81+
TimeUnit.MILLISECONDS,
82+
)
83+
return DisposableHandle { future.cancel(false) }
84+
}
85+
86+
override fun toString(): String = "Dispatchers.Main[Tao]"
87+
}
88+
89+
internal object ImmediateTaoMainDispatcher : TaoMainCoroutineDispatcher() {
90+
override val immediate: MainCoroutineDispatcher get() = this
91+
}
92+
93+
/**
94+
* Shared scheduler for [Delay] callbacks. Single daemon thread — Tao's own
95+
* native timer source isn't exposed at the JVM level, so we rely on a small
96+
* background scheduler that re-posts the timer firing back into the Tao main
97+
* thread via [TaoMainCoroutineDispatcher.dispatch]. The scheduler thread
98+
* itself only schedules — it never runs user code.
99+
*/
100+
private object DelayScheduler {
101+
private val executor: ScheduledExecutorService =
102+
Executors.newSingleThreadScheduledExecutor { r ->
103+
Thread(r, "Nucleus-Tao-Delay").apply { isDaemon = true }
104+
}
105+
106+
fun schedule(
107+
command: Runnable,
108+
delay: Long,
109+
unit: TimeUnit,
110+
) = executor.schedule(command, delay, unit)
111+
}

decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoMainDispatcher.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,20 @@ import kotlin.coroutines.CoroutineContext
2222
internal object TaoMainDispatcher : CoroutineDispatcher() {
2323
private val pending = ConcurrentLinkedQueue<Runnable>()
2424

25+
/**
26+
* Thread reference for the Tao main thread. Captured eagerly from
27+
* [TaoApplication.run] before [NativeTaoBridge.nativeRunBlocking] takes the
28+
* thread over, so it is non-null as soon as user composition runs.
29+
*
30+
* Consumed by [TaoMainCoroutineDispatcher.isDispatchNeeded] and by
31+
* downstream `Dispatchers.Main` resolvers (most notably AndroidX
32+
* Lifecycle's `MainDispatcherChecker`) to recognise the Tao main thread as
33+
* the canonical UI thread.
34+
*/
35+
@Volatile
36+
@JvmField
37+
internal var taoMainThread: Thread? = null
38+
2539
/**
2640
* Coalesces native wake calls within a single pump cycle. Set on the
2741
* first dispatch after pump opened the gate, cleared in [pump] right
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package dev.nucleusframework.window.tao
2+
3+
import kotlinx.coroutines.InternalCoroutinesApi
4+
import kotlinx.coroutines.MainCoroutineDispatcher
5+
import kotlinx.coroutines.internal.MainDispatcherFactory
6+
7+
/**
8+
* `MainDispatcherFactory` that routes `Dispatchers.Main` to the Tao main
9+
* thread when `nucleus.decorated-window-tao` is on the classpath.
10+
*
11+
* Discovered through the standard
12+
* `META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory`
13+
* service file. With [loadPriority] = `100`, it deterministically wins the
14+
* ServiceLoader pick against:
15+
*
16+
* - `kotlinx-coroutines-swing` (`SwingDispatcherFactory.loadPriority = 0`)
17+
* - `kotlinx-coroutines-javafx` (`JavaFxDispatcherFactory.loadPriority = 1`)
18+
*
19+
* `Int.MAX_VALUE` is reserved for `AndroidDispatcherFactory` and
20+
* `Int.MAX_VALUE - 1` for `TestMainDispatcherFactory`. `100` sits well clear
21+
* of both while staying ahead of any reasonable third-party factory.
22+
*
23+
* [createDispatcher] is intentionally cheap and side-effect-free: it only
24+
* returns the [ImmediateTaoMainDispatcher] singleton, with no JNI / native
25+
* interaction. The Tao runtime is initialised separately by
26+
* `nucleusApplication { … }`, which captures the main thread reference
27+
* eagerly via [TaoMainDispatcher.taoMainThread]. This shape is required by
28+
* `kotlinx-coroutines-test`'s [TestMainDispatcherFactory], which wraps the
29+
* resolved dispatcher as the delegate of `TestMainDispatcher`; if the factory
30+
* touched native code here, every test JVM would fail to initialise
31+
* `Dispatchers.Main`.
32+
*/
33+
@OptIn(InternalCoroutinesApi::class)
34+
internal class TaoMainDispatcherFactory : MainDispatcherFactory {
35+
override val loadPriority: Int = 100
36+
37+
override fun createDispatcher(allFactories: List<MainDispatcherFactory>): MainCoroutineDispatcher =
38+
ImmediateTaoMainDispatcher
39+
40+
override fun hintOnError(): String =
41+
"Tao backend's Dispatchers.Main is not initialised. Either call " +
42+
"nucleusApplication(args) { … } from your main() before touching " +
43+
"Dispatchers.Main, or use Dispatchers.setMain(StandardTestDispatcher()) " +
44+
"from kotlinx-coroutines-test in unit tests."
45+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Keep the MainDispatcherFactory implementation — ServiceLoader discovers it
2+
# reflectively from META-INF/services and Compose Desktop's ProGuard pass
3+
# would otherwise strip it as unreferenced, dropping `Dispatchers.Main` back
4+
# to kotlinx-coroutines-swing and breaking AndroidX Lifecycle / Navigation
5+
# under the Tao backend.
6+
-keep class dev.nucleusframework.window.tao.TaoMainDispatcherFactory {
7+
<init>(...);
8+
public *;
9+
}
10+
-keepnames class dev.nucleusframework.window.tao.TaoMainCoroutineDispatcher
11+
-keepnames class dev.nucleusframework.window.tao.ImmediateTaoMainDispatcher
12+
13+
# kotlinx-coroutines' MainDispatcherLoader resolves the factory via the
14+
# service file — keep it from being renamed or removed.
15+
-keep class kotlinx.coroutines.internal.MainDispatcherFactory
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dev.nucleusframework.window.tao.TaoMainDispatcherFactory

0 commit comments

Comments
 (0)