Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,27 @@ import java.util.Arrays
import java.util.Collections
import java.util.UUID

internal fun registerObjcRunnableClass(
className: String,
allocate: (String) -> ID?,
addMethod: (ID) -> Boolean,
register: (ID) -> Unit,
dispose: (ID) -> Unit,
): ID {
val runnableClass = allocate(className)
if (runnableClass == null || runnableClass == ID.NIL) {
throw IllegalStateException("Unable to allocate Objective-C runnable adapter class '$className'")
}

if (!addMethod(runnableClass)) {
dispose(runnableClass)
throw IllegalStateException("Unable to add run: method to Objective-C runnable adapter class '$className'")
}

register(runnableClass)
return runnableClass
}

/**
* see [Documentation](http://developer.apple.com/documentation/Cocoa/Reference/ObjCRuntimeRef/Reference/reference.html)
*/
Expand Down Expand Up @@ -127,6 +148,10 @@ internal object Foundation {
myFoundationLibrary.objc_registerClassPair(cls)
}

fun disposeObjcClassPair(cls: ID?) {
myFoundationLibrary.objc_disposeClassPair(cls)
}

fun isClassRespondsToSelector(cls: ID?, selectorName: Pointer?): Boolean = myFoundationLibrary.class_respondsToSelector(
cls,
selectorName,
Expand Down Expand Up @@ -283,7 +308,7 @@ internal object Foundation {
val isMainThread: Boolean
get() = invoke("NSThread", "isMainThread").booleanValue()

private var ourRunnableCallback: Callback? = null
private var ourRunnableSupport: RunnableSupport? = null
private val ourMainThreadRunnables: MutableMap<String?, RunnableInfo> = HashMap()
private var ourCurrentRunnableCount: Long = 0
private val RUNNABLE_LOCK = Any()
Expand All @@ -293,9 +318,10 @@ internal object Foundation {
waitUntilDone: Boolean,
runnable: Runnable,
) {
val runnableSupport: RunnableSupport
var runnableCountString: String?
synchronized(RUNNABLE_LOCK) {
initRunnableSupport()
runnableSupport = initRunnableSupport()
runnableCountString = (++ourCurrentRunnableCount).toString()
ourMainThreadRunnables.put(
runnableCountString,
Expand All @@ -304,8 +330,7 @@ internal object Foundation {
}

// fixme: Use Grand Central Dispatch instead?
val ideaRunnable = getObjcClass("IdeaRunnable")
val runnableObject = invoke(invoke(ideaRunnable, "alloc"), "init")
val runnableObject = invoke(invoke(runnableSupport.runnableClass, "alloc"), "init")
val keyObject = invoke(nsString(runnableCountString), "retain")
invoke(
runnableObject,
Expand All @@ -318,48 +343,56 @@ internal object Foundation {
}

/**
* Registers idea runnable adapter class in ObjC runtime, if not registered yet.
* Registers the FileKit-owned runnable adapter class in the Objective-C runtime, if not registered yet.
*
*
* Warning: NOT THREAD-SAFE! Must be called under lock. Danger of segmentation fault.
* Warning: NOT THREAD-SAFE! Must be called under [RUNNABLE_LOCK].
*/
private fun initRunnableSupport() {
if (ourRunnableCallback == null) {
val runnableClass = allocateObjcClassPair(getObjcClass("NSObject"), "IdeaRunnable")
registerObjcClassPair(runnableClass)

val callback: Callback = object : Callback {
fun callback(self: ID?, selector: String?, keyObject: ID?) {
val key = toStringViaUTF8(keyObject)
invoke(keyObject, "release")

var info: RunnableInfo?
synchronized(RUNNABLE_LOCK) {
info = ourMainThreadRunnables.remove(key)
}
private fun initRunnableSupport(): RunnableSupport {
ourRunnableSupport?.let { return it }

if (info == null) {
return
val callback: Callback = object : Callback {
fun callback(self: ID?, selector: String?, keyObject: ID?) {
val key = toStringViaUTF8(keyObject)
invoke(keyObject, "release")

var info: RunnableInfo?
synchronized(RUNNABLE_LOCK) {
info = ourMainThreadRunnables.remove(key)
}

if (info == null) {
return
}

var pool: ID? = null
try {
if (info.myUseAutoreleasePool) {
pool = invoke("NSAutoreleasePool", "new")
}

var pool: ID? = null
try {
if (info.myUseAutoreleasePool) {
pool = invoke("NSAutoreleasePool", "new")
}

info.myRunnable.run()
} finally {
if (pool != null) {
invoke(pool, "release")
}
info.myRunnable.run()
} finally {
if (pool != null) {
invoke(pool, "release")
}
}
}
if (!addMethod(runnableClass, createSelector("run:"), callback, "v@:*")) {
throw RuntimeException("Unable to add method to objective-c runnableClass class!")
}
ourRunnableCallback = callback
}

val runnableClass = registerObjcRunnableClass(
className = RUNNABLE_ADAPTER_CLASS_NAME,
allocate = { className ->
allocateObjcClassPair(getObjcClass("NSObject"), className)
},
addMethod = { allocatedClass ->
addMethod(allocatedClass, createSelector("run:"), callback, "v@:*")
},
register = ::registerObjcClassPair,
dispose = ::disposeObjcClassPair,
)
return RunnableSupport(runnableClass, callback).also {
ourRunnableSupport = it
}
}

Expand Down Expand Up @@ -449,6 +482,13 @@ internal object Foundation {
}
}

private class RunnableSupport(
val runnableClass: ID,
val runnableCallback: Callback,
)

private const val RUNNABLE_ADAPTER_CLASS_NAME = "FileKitMainThreadRunnable"

internal class RunnableInfo(
var myRunnable: Runnable,
var myUseAutoreleasePool: Boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ internal interface FoundationLibrary : Library {

fun objc_registerClassPair(cls: ID?)

fun objc_disposeClassPair(cls: ID?)

fun CFStringCreateWithBytes(
allocator: Pointer?,
bytes: ByteArray?,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package io.github.vinceglb.filekit.dialogs.platform.mac.foundation

import io.github.vinceglb.filekit.utils.Platform
import io.github.vinceglb.filekit.utils.PlatformUtil
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue

@Suppress("ktlint:standard:function-naming", "FunctionName")
class FoundationRunnableIntegrationTest {
@Test
fun Foundation_executeOnMainThread_whenIdeaRunnableAlreadyExists_executesRunnable() {
if (PlatformUtil.current != Platform.MacOS) return

registerForeignIdeaRunnableIfNeeded()
assertFalse(Foundation.isNil(Foundation.getObjcClass(LEGACY_CLASS_NAME)))
val executed = AtomicBoolean(false)

Foundation.executeOnMainThread(
withAutoreleasePool = true,
waitUntilDone = true,
runnable = Runnable { executed.set(true) },
)

assertTrue(executed.get())
assertFalse(Foundation.isNil(Foundation.getObjcClass("FileKitMainThreadRunnable")))
}

private fun registerForeignIdeaRunnableIfNeeded() {
if (!Foundation.isNil(Foundation.getObjcClass(LEGACY_CLASS_NAME))) return

val nsObject = Foundation.getObjcClass("NSObject")
check(!Foundation.isNil(nsObject)) {
"Unable to resolve NSObject while preparing the runnable adapter collision"
}

val foreignClass = Foundation.allocateObjcClassPair(nsObject, LEGACY_CLASS_NAME)
check(!Foundation.isNil(foreignClass)) {
"Unable to allocate the foreign $LEGACY_CLASS_NAME class"
}

Foundation.registerObjcClassPair(foreignClass)
}

private companion object {
const val LEGACY_CLASS_NAME = "IdeaRunnable"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package io.github.vinceglb.filekit.dialogs.platform.mac.foundation

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertSame

@Suppress("ktlint:standard:function-naming", "FunctionName")
class ObjcRunnableClassRegistrationTest {
@Test
fun ObjcRunnableClassRegistration_nullAllocation_rejectsBeforeNativeMutation() {
val events = mutableListOf<String>()

val exception = assertFailsWith<IllegalStateException> {
registerObjcRunnableClass(
className = CLASS_NAME,
allocate = {
events += "allocate $it"
null
},
addMethod = {
events += "add method"
true
},
register = { events += "register" },
dispose = { events += "dispose" },
)
}

assertEquals("Unable to allocate Objective-C runnable adapter class '$CLASS_NAME'", exception.message)
assertEquals(listOf("allocate $CLASS_NAME"), events)
}

@Test
fun ObjcRunnableClassRegistration_zeroValuedAllocation_rejectsBeforeNativeMutation() {
val events = mutableListOf<String>()

val exception = assertFailsWith<IllegalStateException> {
registerObjcRunnableClass(
className = CLASS_NAME,
allocate = {
events += "allocate $it"
ID(0)
},
addMethod = {
events += "add method"
true
},
register = { events += "register" },
dispose = { events += "dispose" },
)
}

assertEquals("Unable to allocate Objective-C runnable adapter class '$CLASS_NAME'", exception.message)
assertEquals(listOf("allocate $CLASS_NAME"), events)
}

@Test
fun ObjcRunnableClassRegistration_addMethodFailure_disposesBeforeRegistration() {
val runnableClass = ID(42)
val events = mutableListOf<String>()

val exception = assertFailsWith<IllegalStateException> {
registerObjcRunnableClass(
className = CLASS_NAME,
allocate = {
events += "allocate $it"
runnableClass
},
addMethod = {
assertSame(runnableClass, it)
events += "add method"
false
},
register = { events += "register" },
dispose = {
assertSame(runnableClass, it)
events += "dispose"
},
)
}

assertEquals("Unable to add run: method to Objective-C runnable adapter class '$CLASS_NAME'", exception.message)
assertEquals(listOf("allocate $CLASS_NAME", "add method", "dispose"), events)
}

@Test
fun ObjcRunnableClassRegistration_success_addsMethodBeforeRegistration() {
val runnableClass = ID(42)
val events = mutableListOf<String>()

val result = registerObjcRunnableClass(
className = CLASS_NAME,
allocate = {
events += "allocate $it"
runnableClass
},
addMethod = {
assertSame(runnableClass, it)
events += "add method"
true
},
register = {
assertSame(runnableClass, it)
events += "register"
},
dispose = { events += "dispose" },
)

assertSame(runnableClass, result)
assertEquals(listOf("allocate $CLASS_NAME", "add method", "register"), events)
}

private companion object {
const val CLASS_NAME = "FileKitMainThreadRunnable"
}
}
21 changes: 21 additions & 0 deletions specs/issue-626-macos-runnable-class-collision/CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# macOS JVM Main-Thread Dispatch

This context describes FileKit's macOS JVM main-thread dispatch language. It exists to distinguish work and state owned by one FileKit runtime from similarly named state owned elsewhere in the process.

## Language

**Runnable Adapter**:
A FileKit-owned bridge that carries a JVM runnable ticket onto the macOS application thread.
_Avoid_: IdeaRunnable, global helper

**Runnable Ticket**:
The opaque identifier that connects one scheduled main-thread callback to the corresponding JVM work.
_Avoid_: Runnable pointer, callback state

**Foreign Adapter**:
A main-thread bridge owned by code outside the current FileKit runtime owner, even when its name or behavior appears compatible.
_Avoid_: Reusable adapter, shared helper

**Runtime Owner**:
The FileKit runtime instance that owns one runnable adapter and its pending runnable tickets as a single lifecycle.
_Avoid_: Global helper, shared adapter
Loading