diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/Foundation.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/Foundation.kt index c88ae3d2..cce8c8e0 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/Foundation.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/Foundation.kt @@ -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) */ @@ -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, @@ -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 = HashMap() private var ourCurrentRunnableCount: Long = 0 private val RUNNABLE_LOCK = Any() @@ -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, @@ -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, @@ -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 } } @@ -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, diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/FoundationLibrary.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/FoundationLibrary.kt index 763d5700..10bd4ad7 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/FoundationLibrary.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/FoundationLibrary.kt @@ -14,6 +14,8 @@ internal interface FoundationLibrary : Library { fun objc_registerClassPair(cls: ID?) + fun objc_disposeClassPair(cls: ID?) + fun CFStringCreateWithBytes( allocator: Pointer?, bytes: ByteArray?, diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/FoundationRunnableIntegrationTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/FoundationRunnableIntegrationTest.kt new file mode 100644 index 00000000..c65cd304 --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/FoundationRunnableIntegrationTest.kt @@ -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" + } +} diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/ObjcRunnableClassRegistrationTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/ObjcRunnableClassRegistrationTest.kt new file mode 100644 index 00000000..e3f3f986 --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/ObjcRunnableClassRegistrationTest.kt @@ -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() + + val exception = assertFailsWith { + 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() + + val exception = assertFailsWith { + 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() + + val exception = assertFailsWith { + 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() + + 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" + } +} diff --git a/specs/issue-626-macos-runnable-class-collision/CONTEXT.md b/specs/issue-626-macos-runnable-class-collision/CONTEXT.md new file mode 100644 index 00000000..777573c7 --- /dev/null +++ b/specs/issue-626-macos-runnable-class-collision/CONTEXT.md @@ -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 diff --git a/specs/issue-626-macos-runnable-class-collision/PLAN.md b/specs/issue-626-macos-runnable-class-collision/PLAN.md new file mode 100644 index 00000000..1dcf2871 --- /dev/null +++ b/specs/issue-626-macos-runnable-class-collision/PLAN.md @@ -0,0 +1,236 @@ +# Issue 626: Prevent macOS JVM Runnable-Class Collisions + +## Status + +Ready for independent plan review. + +Issue: [#626](https://github.com/vinceglb/FileKit/issues/626) + +Baseline: `origin/main` at `80b901d05a260b7449cadba770d3b033dc063cb7` + +## Objective + +Prevent a macOS/JVM picker from crashing in `objc_registerClassPair` when another dependency has already registered IntelliJ's process-global `IdeaRunnable` class name. Keep FileKit's existing main-thread dispatch behavior and public API intact. + +## Verified Problem + +The issue is real and remains present on the current baseline: + +- `Foundation.initRunnableSupport()` allocates the Objective-C class `IdeaRunnable` and passes the result directly to `objc_registerClassPair`. +- `Foundation.executeOnMainThread()` subsequently retrieves `IdeaRunnable` by its global name rather than retaining the class FileKit created. +- Apple documents that Objective-C class names occupy a process-wide namespace and that `objc_allocateClassPair` returns `Nil` when the desired name is already in use. +- FileKit maps the native return to `ID : NativeLong`. A safe standalone JNA 5.19.1 probe on this macOS host registered a unique class and allocated it again. The second allocation produced a non-null `ID` whose numeric value was `0`: + + ```text + collision.isNull=false + collision.value=0 + ``` + + Kotlin nullability alone therefore does not detect this failure; `Foundation.isNil` does. +- The implementation is unchanged from FileKit 0.14.2 in the affected area. The issue's stack and zero pointer are consistent with passing this value into `objc_registerClassPair`. + +Primary references: + +- [Apple: `objc_allocateClassPair`](https://developer.apple.com/documentation/objectivec/objc_allocateclasspair%28_%3A_%3A_%3A%29?language=objc) +- [Apple: class names must be unique across an app and its frameworks](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ProgrammingWithObjectiveC/Conventions/Conventions.html) +- [Apple: Objective-C has a flat, process-global namespace](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/LoadingCode/Tasks/NameConflicts.html) +- [JetBrains commit that introduced the original internal `IdeaRunnable` adapter](https://github.com/JetBrains/intellij-community/commit/37dd1fb1395dd204dd8b0ee52f0fe59cf6a1ce43) +- [Gradle: JVM tests execute in a separate forked JVM](https://docs.gradle.org/current/userguide/java_testing.html) + +## Autonomous Grilling Record + +The requested grilling session was resolved without user interruption because the issue, repository, and platform contracts provide sufficient evidence. + +### 1. Does FileKit own `IdeaRunnable`? + +**Recommendation and decision:** No. Treat any class already registered under that name as foreign state. FileKit copied an IntelliJ-internal adapter into a reusable library; the name does not identify FileKit ownership. + +Do not look up and reuse an existing `IdeaRunnable`. Its `run:` implementation and JNA callback lifetime can belong to unrelated code. + +### 2. How should the adapter be named? + +**Recommendation and decision:** Register a FileKit-specific name, `FileKitMainThreadRunnable`. + +This follows Apple's framework-prefix guidance and removes the demonstrated collision with IntelliJ/JBR code. Keep an explicit allocation guard even with the stronger name. A second FileKit copy loaded through another class loader may still contend for the fixed process-global name; it must fail with a managed JVM exception rather than attach to another runtime owner's callback. + +Do not generate a random class name in this fix. Supporting multiple independently loaded FileKit runtimes would add a new lifecycle contract and is not required by #626. + +### 3. What counts as failed allocation? + +**Recommendation and decision:** Both Kotlin `null` and a zero-valued `ID` are failure. + +Use `Foundation.isNil` (or a testable helper with exactly the same semantics) immediately after allocation. Throw a descriptive `IllegalStateException` before calling `class_addMethod`, `objc_registerClassPair`, or looking up the class by name. + +### 4. May FileKit reuse a same-named class? + +**Recommendation and decision:** No, including a class named `FileKitMainThreadRunnable`. + +Objective-C method shape is insufficient proof of ownership. A class from another FileKit class loader would retain a callback into different JVM state. A collision must be a controlled initialization failure. + +### 5. What is the safe initialization order? + +**Recommendation and decision:** Construct the callback, allocate the class, reject nil, add `run:`, register the completed class, then publish the owned support state. + +Apple's documented sequence is allocation, class customization, then registration. The current register-before-`class_addMethod` order can leave a permanently registered but incomplete class when method installation fails. + +If `class_addMethod` returns false, call `objc_disposeClassPair` on the still-unregistered class and throw. Do not publish either the class or callback before registration completes. Add the JNA binding for `objc_disposeClassPair`; do not attempt to dispose a registered class. + +### 6. What state proves successful initialization? + +**Recommendation and decision:** One private support value containing both the created class `ID` and the strong JNA `Callback` reference. + +Return that support value from initialization while holding `RUNNABLE_LOCK`, and use its stored class `ID` when creating each adapter instance. Do not perform a global `getObjcClass(name)` lookup after initialization. This makes ownership explicit and prevents later code from silently selecting foreign state. + +### 7. What concurrency guarantee is required? + +**Recommendation and decision:** Preserve the existing single initialization and runnable-ticket mutation under `RUNNABLE_LOCK`. + +Within one FileKit runtime owner, concurrent callers must observe the same fully initialized support value. The Objective-C namespace is process-global, but a lock in one class loader cannot coordinate unrelated owners; the nil guard is the safe boundary for that case. No process-wide inter-library lock or foreign-class adoption belongs in this issue. + +### 8. How should the crash path be tested safely? + +**Recommendation and decision:** Combine a platform-independent lifecycle seam with one macOS-gated native regression test. + +The pure tests must simulate Kotlin null, `ID(0)`, method-installation failure, and success without invoking native registration. The native test must pre-register `IdeaRunnable`, then run FileKit's main-thread adapter and verify the runnable executes. It must not open a picker. + +Gradle executes JVM tests in a forked test JVM, separate from the Gradle daemon. If the native regression is reintroduced, the test worker can terminate abnormally while the daemon and developer session remain isolated. Keep the native scenario in its own test class so it can be run alone while diagnosing a failure. + +### 9. How should the native test be gated? + +**Recommendation and decision:** Keep it in `jvmTest`, but return immediately unless `PlatformUtil.current == Platform.MacOS`, matching existing repository practice. + +No build-script task or new source set is needed. The `test-desktop` CI matrix already runs `./gradlew jvmTest` on `macos-26`, so the regression executes on a real macOS Objective-C runtime while Linux and Windows continue to compile it without loading Foundation. + +### 10. Should this replace the adapter with Grand Central Dispatch? + +**Recommendation and decision:** No. + +GCD could remove dynamic class registration, but it changes the scheduling bridge, callback lifetime, and native bindings. The namespaced adapter plus defensive lifecycle fixes the reported crash with a small, reviewable change. Leave the existing GCD TODO and broader Foundation modernization for separate work. + +### 11. Does this need a public ADR or Mintlify update? + +**Recommendation and decision:** No. + +The change is internal, issue-scoped, and reversible; it does not satisfy the threshold for an architectural decision record. It changes no public API or documented picker behavior. Keep this plan and glossary in `specs/`; do not modify `docs/`. + +## Implementation Plan + +### 1. Make Objective-C class construction defensive + +Update: + +- `filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/FoundationLibrary.kt` +- `filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/Foundation.kt` + +Changes: + +1. Add the `objc_disposeClassPair` JNA declaration and a narrowly scoped wrapper only if the lifecycle helper needs it. +2. Introduce a small internal top-level, platform-independent registration helper in `Foundation.kt` (or a sibling internal file only if that keeps the lifecycle testable). It must accept operations for allocate, add-method, register, and dispose so tests can verify behavior without initializing the native-loading `Foundation` object. +3. Make the helper: + - reject both `null` and `ID(0)` allocation results; + - add `run:` before registration; + - dispose the unregistered class if method addition returns false; + - never register or dispose a nil class; + - return the created class only after registration completes. +4. Use the fixed name `FileKitMainThreadRunnable`. +5. Replace `ourRunnableCallback` as the initialization flag with a private support value that retains both the class `ID` and callback. +6. Keep initialization under `RUNNABLE_LOCK`, return the initialized support to `executeOnMainThread`, and allocate the adapter instance from the stored class `ID`. +7. Update comments and local names from IntelliJ/`IdeaRunnable` terminology to the glossary's runnable-adapter language. + +Do not change runnable-ticket generation, autorelease-pool behavior, `performSelectorOnMainThread:withObject:waitUntilDone:`, picker code, or any public declaration. + +### 2. Add deterministic lifecycle regression tests + +Add: + +- `filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/ObjcRunnableClassRegistrationTest.kt` + +Cover: + +1. A Kotlin-null allocation throws a descriptive managed exception and does not add, register, or dispose. +2. A non-null `ID(0)` allocation does the same. +3. A failed `class_addMethod` disposes the allocated, unregistered class exactly once and never registers it. +4. A successful path records the exact order `allocate -> add method -> register`, returns the allocated class, and does not dispose it. + +Keep these tests native-free and runnable on every JVM CI host. + +### 3. Add the real macOS collision regression + +Add: + +- `filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/FoundationRunnableIntegrationTest.kt` + +On macOS only: + +1. Resolve `NSObject`. +2. If `IdeaRunnable` is absent, allocate and register a minimal class with that exact name. If it is already present, leave it untouched. +3. Call `Foundation.executeOnMainThread(withAutoreleasePool = true, waitUntilDone = true)` with a runnable that records execution. +4. Assert the runnable completed. + +The test must not call `objc_registerClassPair` with a nil value, reuse the foreign class, show a picker, depend on a UI selection, or attempt to dispose the registered foreign class. On non-macOS hosts, return before touching `Foundation`. + +## Acceptance Criteria + +- A process containing `IdeaRunnable` can initialize FileKit's adapter and execute a runnable without `SIGSEGV`. +- FileKit registers and uses `FileKitMainThreadRunnable`, not `IdeaRunnable`. +- Kotlin-null and zero-valued JNA allocation failures become descriptive JVM exceptions before any unsafe native call. +- The `run:` method is added before `objc_registerClassPair`. +- Pre-registration failure does not leak an allocated class; a registered class is never disposed. +- Callback and class ownership are published together and retained for the runtime owner's lifetime. +- Concurrent calls within one FileKit runtime remain serialized during initialization and share one support value. +- No public API, picker result, platform other than macOS/JVM, Mintlify page, sample, or dependency changes. +- Focused tests, the module check, the repository-wide `check`, assembly, formatting, and CI all pass. + +## Verification + +Run in this order: + +```bash +./gradlew :filekit-dialogs:jvmTest \ + --tests 'io.github.vinceglb.filekit.dialogs.platform.mac.foundation.ObjcRunnableClassRegistrationTest' \ + --no-daemon + +./gradlew :filekit-dialogs:jvmTest \ + --tests 'io.github.vinceglb.filekit.dialogs.platform.mac.foundation.FoundationRunnableIntegrationTest' \ + --no-daemon + +./gradlew :filekit-dialogs:check --no-daemon +./gradlew check --no-daemon +./gradlew assemble --no-daemon + +KTLINT_DIR="$(mktemp -d)" +curl -sSLo "$KTLINT_DIR/ktlint" \ + https://github.com/pinterest/ktlint/releases/download/1.8.0/ktlint +chmod +x "$KTLINT_DIR/ktlint" +curl -sSLo "$KTLINT_DIR/ktlint-compose-0.6.0-all.jar" \ + https://github.com/mrmans0n/compose-rules/releases/download/v0.6.0/ktlint-compose-0.6.0-all.jar + +"$KTLINT_DIR/ktlint" \ + filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/Foundation.kt \ + filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/FoundationLibrary.kt \ + filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/ObjcRunnableClassRegistrationTest.kt \ + filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/FoundationRunnableIntegrationTest.kt \ + -R "$KTLINT_DIR/ktlint-compose-0.6.0-all.jar" + +git diff --check +``` + +The lint setup mirrors `.github/workflows/ci.yml` versions while keeping downloaded tools outside the checkout. The second Gradle command is meaningful only on macOS; record the macOS and JDK versions in the PR. No manual picker interaction is required because the native integration test exercises the failing adapter path directly and deterministically. If a UI smoke test is performed, treat it as supplemental rather than a replacement for the collision regression. + +## Pull Request and CI Notes + +- Use `Fixes #626`. +- State that the affected target is macOS/JVM only and that the public API is unchanged. +- Explain the non-null zero-valued JNA result and why `isNil` is required. +- Call out that the macOS native regression intentionally pre-registers the foreign legacy name. +- Confirm the `test-desktop` `macos-26` job executed the integration test; a green Linux/Windows skip alone is insufficient evidence. +- Do not add a release note, sample change, or Mintlify documentation for this internal crash fix unless maintainers request one during PR review. + +## Out of Scope + +- Replacing `performSelectorOnMainThread` with GCD. +- Refactoring unrelated Foundation/JNA bindings. +- Reusing or mutating any pre-existing Objective-C class. +- Supporting multiple isolated FileKit copies or class loaders in one process beyond safe, managed collision failure. +- Changing picker threading, modality, autorelease behavior, or public APIs. +- Updating `docs/`.