Skip to content

fix(ADFA-4384): stop Kotlin LSP index workers before disposing project#1424

Merged
hal-eisen-adfa merged 3 commits into
stagefrom
fix/ADFA-4384-kotlin-index-dispose-crash
Jun 19, 2026
Merged

fix(ADFA-4384): stop Kotlin LSP index workers before disposing project#1424
hal-eisen-adfa merged 3 commits into
stagefrom
fix/ADFA-4384-kotlin-index-dispose-crash

Conversation

@hal-eisen-adfa

Copy link
Copy Markdown
Collaborator

Summary

Fixes the top unassigned Sentry crash APPDEVFORALL-17R / ADFA-4384AssertionError: Project is already disposed.

Root cause

The Kotlin LSP background IndexWorker runs an unbounded coroutine loop that calls PsiManager.findFile(project) under a read lock. On LSP shutdown, AbstractCompilationEnvironment.close() disposed the IntelliJ Project immediately (Disposer.dispose(...)) while the worker coroutine was still live. The next findFile() then threw AssertionError: Project is already disposed on a coroutine worker thread → fatal crash.

KtSymbolIndex.close() already implements the orderly shutdown (submit Stop, join the worker jobs) — but nothing ever called it.

Fix

  • AbstractCompilationEnvironment.close() — stop & join the index workers via ktSymbolIndex.close() before Disposer.dispose(...). (Placed in the base class so the ::ktSymbolIndex.isInitialized check can legally reach the lateinit backing field.)
  • CompilationEnvironment.close() — cancel coroutineScope (the fileAnalyzer also reads the project) before super.close().
  • KtSymbolIndex.close() — cancel its own scope after joining, so the debounced modifiedFileIndexer can't fire post-dispose.
  • IndexWorker — defensive project.isDisposed guards (loop top + before each findFile) for any disposal path that doesn't drain the worker first.

Testing

  • New IndexWorkerDisposalTest (JVM/Robolectric, no emulator) disposes the project via the real production path and asserts the worker exits cleanly.
  • Negative control: with the guards removed the test reproduces the exact AssertionError — confirming it is a real regression test.
  • Full :lsp:kotlin unit suite green: 41 tests, 0 failures.
  • Also documents the LSP test-env disposal gotcha (runWriteAction { env.close() }) in docs/process/learnings.md.

Fixes APPDEVFORALL-17R

The Kotlin LSP background IndexWorker runs an unbounded coroutine loop that
calls PsiManager.findFile(project) under a read lock. On LSP shutdown,
AbstractCompilationEnvironment.close() disposed the IntelliJ Project
immediately via Disposer.dispose(...), while the worker coroutine was still
live. The next findFile() then threw "AssertionError: Project is already
disposed", crashing the app.

KtSymbolIndex.close() already implements the orderly shutdown (submit Stop,
join the worker jobs) but nothing called it. Fix:

- AbstractCompilationEnvironment.close(): stop & join the index workers via
  ktSymbolIndex.close() before Disposer.dispose(...). (In the base class so
  the lateinit isInitialized check is legal.)
- CompilationEnvironment.close(): cancel coroutineScope (fileAnalyzer also
  reads the project) before super.close().
- KtSymbolIndex.close(): cancel its own scope after joining so the debounced
  modifiedFileIndexer can't fire post-dispose.
- IndexWorker: defensive project.isDisposed guards for any disposal path that
  doesn't drain the worker first.

Adds IndexWorkerDisposalTest (reproduces the crash without the guards) and a
docs/process/learnings.md note on disposing the LSP test env inside a write
action.

Fixes APPDEVFORALL-17R
@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a0e3edff-36d3-43e0-93c3-ce910a565ff0

📥 Commits

Reviewing files that changed from the base of the PR and between 1c93342 and 6e3d2a1.

📒 Files selected for processing (3)
  • lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/AbstractCompilationEnvironment.kt
  • lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt
  • lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/SourceFileIndexer.kt
🚧 Files skipped from review as they are similar to previous changes (2)
  • lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/SourceFileIndexer.kt
  • lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/AbstractCompilationEnvironment.kt

📝 Walkthrough

Release Notes

  • Fixed critical Kotlin LSP crash (AssertionError: Project is already disposed) by ensuring index/compilation workers stop before the IntelliJ project is disposed.
  • Implemented orderly shutdown:
    • AbstractCompilationEnvironment.close(): drains/stops ktSymbolIndex via runBlocking with a bounded timeout to avoid ANRs, before calling Disposer.dispose(...).
    • CompilationEnvironment.close(): cancels and joins the environment coroutine scope before delegating to superclass cleanup.
    • KtSymbolIndex.close(): cancels and joins its internal scope after stopping indexing workers to prevent post-disposal callbacks.
  • Added defensive lifecycle guards to prevent PSI/project access during teardown:
    • IndexWorker.start(): added project.isDisposed checks before/around PsiManager/project.read usage.
    • SourceFileIndexer.indexSourceFile(): added disposal-aware fast paths and ensured disposal checks occur inside the PSI project.read critical sections (including returning empty results when disposal happens mid-read).
  • Added regression coverage:
    • IndexWorkerDisposalTest reproduces the disposal race and verifies the worker stops without crashing under teardown conditions.
  • Documented Kotlin LSP test-harness teardown constraints in docs/process/learnings.md.

Risks & Best Practices Concerns

  • ⚠️ Shutdown path blocks with runBlocking; mitigated by a timeout-bounded drain to reduce ANR risk during editor teardown.
  • ⚠️ Added reliance on correct lifecycle ordering plus defensive fallbacks (project.isDisposed guards); other disposal paths may still be sensitive if they bypass the updated shutdown sequence.
  • ⚠️ Test harness lifecycle: disposal can require teardown to run inside an IntelliJ write action (documented in docs/process/learnings.md).

Walkthrough

Fixes a crash where background IndexWorker coroutines access an already-disposed IntelliJ project. The fix enforces a teardown order: CompilationEnvironment cancels its coroutine scope first, AbstractCompilationEnvironment synchronously closes KtSymbolIndex (stopping/joining workers and cancelling its scope) before calling Disposer.dispose, and IndexWorker and SourceFileIndexer add project.isDisposed early-exit guards in their processing paths. A regression test and learnings doc are also added.

Changes

IndexWorker Disposal Race Fix

Layer / File(s) Summary
Shutdown ordering in CompilationEnvironment and KtSymbolIndex
lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt, lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/AbstractCompilationEnvironment.kt, lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt
CompilationEnvironment.close() adds imports and cancels coroutineScope before super.close(); AbstractCompilationEnvironment.close() introduces CLOSE_DRAIN_TIMEOUT_MS and runs ktSymbolIndex.close() via runBlocking with timeout before disposal; KtSymbolIndex.close() cancels its own scope after stopping workers.
Defensive isDisposed guards in IndexWorker and SourceFileIndexer
lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt, lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/SourceFileIndexer.kt
IndexWorker.start() adds three project.isDisposed early-exit checks before queue.take() and before IndexSourceFile/ScanSourceFile call project.read(). SourceFileIndexer.indexSourceFile() adds early-exit and re-check guards to prevent PSI access on disposed projects.
Regression test and teardown docs
lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorkerDisposalTest.kt, docs/process/learnings.md
IndexWorkerDisposalTest disposes the project via a write action and verifies worker.start() finishes cleanly within 5 seconds. learnings.md records the write-action teardown requirement and the index shutdown race.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • appdevforall/CodeOnTheGo#1142: Both PRs modify CompilationEnvironment.kt's close() lifecycle/teardown behavior—main PR cancels coroutineScope before superclass disposal; linked PR additionally manages session lifecycle during shutdown.

Suggested reviewers

  • Daniel-ADFA
  • jatezzz

Poem

🐇 A worker once ran in the night,
After the project had gone from sight.
We cancelled the scope,
And added some hope —
Now teardown completes just right! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly describes the main change: preventing crashes by stopping Kotlin LSP index workers before disposing the project.
Description check ✅ Passed The description comprehensively explains the crash root cause, the implemented fix across multiple files, and testing approach including regression tests.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ADFA-4384-kotlin-index-dispose-crash

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt (1)

52-59: ⚠️ Potential issue | 🟠 Major

Add the same disposal guard to the debounced modified-file callback.

The modifiedFileIndexer callback (line 55-59) calls indexSourceFile(project, ...) without a project.isDisposed check. Although KtSymbolIndex.close() cancels the scope before project disposal, a race condition remains: the debounced action can execute between the start of scope cancellation and its completion, causing the callback to access a disposed project. Add the guard to match the pattern already used in the main loop (lines 65, 80, 121).

✅ Suggested patch
 		val modifiedFileIndexer = KeyedDebouncingAction<ModFileIndexKey>(
 			scope = scope,
 			debounceDuration = CompilationEnvironment.DEFAULT_FILE_MOD_EVENT_DEBOUNCE_DURATION
 		) { (path, ktFile), cancelChecker ->
+			if (project.isDisposed) return@KeyedDebouncingAction
 			logger.debug("Indexing modified file: {}", path)
 			indexSourceFile(project, ktFile, fileIndex, sourceIndex, cancelChecker)
 			sourceIndexCount++
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt`
around lines 52 - 59, The modifiedFileIndexer callback does not check if the
project is disposed before calling indexSourceFile, creating a race condition
where the debounced action may execute after scope cancellation begins but
before it completes, causing access to a disposed project. Add a
project.isDisposed check at the beginning of the callback lambda in
modifiedFileIndexer (the one with parameters (path, ktFile), cancelChecker)
before the indexSourceFile call, following the same pattern used elsewhere in
the code at lines 65, 80, and 121. If the project is disposed, simply return
early from the callback without executing the indexSourceFile call.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In
`@lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt`:
- Around line 52-59: The modifiedFileIndexer callback does not check if the
project is disposed before calling indexSourceFile, creating a race condition
where the debounced action may execute after scope cancellation begins but
before it completes, causing access to a disposed project. Add a
project.isDisposed check at the beginning of the callback lambda in
modifiedFileIndexer (the one with parameters (path, ktFile), cancelChecker)
before the indexSourceFile call, following the same pattern used elsewhere in
the code at lines 65, 80, and 121. If the project is disposed, simply return
early from the callback without executing the indexSourceFile call.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c70b10fa-8f96-49dc-bdc0-f8f0fb0d2342

📥 Commits

Reviewing files that changed from the base of the PR and between d73ec0b and 468d3b6.

📒 Files selected for processing (6)
  • docs/process/learnings.md
  • lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/AbstractCompilationEnvironment.kt
  • lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt
  • lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt
  • lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt
  • lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorkerDisposalTest.kt

Signed-off-by: Akash Yadav <itsaky01@gmail.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/SourceFileIndexer.kt`:
- Around line 77-82: The disposal check for project.isDisposed is non-atomic
with the project.read operation that happens inside the toMetadata() method
call, allowing disposal to occur between the check and the actual invocation.
Wrap the ktFile.toMetadata(project, isIndexed = true) call in an atomic read
operation using project.read or a similar mechanism that ensures the disposal
check and the metadata retrieval happen atomically, preventing any race
condition where the project could be disposed between the defensive check and
the method execution.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 87e8ecee-22e2-4d3f-b91a-f40c4077f5ed

📥 Commits

Reviewing files that changed from the base of the PR and between 468d3b6 and 1c93342.

📒 Files selected for processing (2)
  • lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt
  • lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/SourceFileIndexer.kt

…er, atomic disposal check

Follow-up to code review on the disposal fix:

- AbstractCompilationEnvironment.close(): runBlocking { ktSymbolIndex.close() }
  runs on the main thread during editor teardown; bound it with
  withTimeoutOrNull(CLOSE_DRAIN_TIMEOUT_MS) so a slow PSI read can't ANR. The
  project.isDisposed guards cover the rare timeout-before-drain case.
- CompilationEnvironment.close(): the fileAnalyzer scope was only cancel()'d
  (fire-and-forget) before disposal, so an in-flight collectDiagnosticsFor
  project.read could still touch a disposed project. Cancel AND join it
  (bounded) before super.close().
- SourceFileIndexer.indexSourceFile(): the project.isDisposed fast-path was
  non-atomic with toMetadata()'s internal project.read. Re-check disposal
  inside the read lock so the check and the PSI access are atomic.

All :lsp:kotlin unit tests pass (41, 0 failures).
@hal-eisen-adfa hal-eisen-adfa merged commit 7acac35 into stage Jun 19, 2026
2 checks passed
@hal-eisen-adfa hal-eisen-adfa deleted the fix/ADFA-4384-kotlin-index-dispose-crash branch June 19, 2026 17:44
jatezzz pushed a commit that referenced this pull request Jun 22, 2026
#1424)

* fix(ADFA-4384): stop Kotlin LSP index workers before disposing project

The Kotlin LSP background IndexWorker runs an unbounded coroutine loop that
calls PsiManager.findFile(project) under a read lock. On LSP shutdown,
AbstractCompilationEnvironment.close() disposed the IntelliJ Project
immediately via Disposer.dispose(...), while the worker coroutine was still
live. The next findFile() then threw "AssertionError: Project is already
disposed", crashing the app.

KtSymbolIndex.close() already implements the orderly shutdown (submit Stop,
join the worker jobs) but nothing called it. Fix:

- AbstractCompilationEnvironment.close(): stop & join the index workers via
  ktSymbolIndex.close() before Disposer.dispose(...). (In the base class so
  the lateinit isInitialized check is legal.)
- CompilationEnvironment.close(): cancel coroutineScope (fileAnalyzer also
  reads the project) before super.close().
- KtSymbolIndex.close(): cancel its own scope after joining so the debounced
  modifiedFileIndexer can't fire post-dispose.
- IndexWorker: defensive project.isDisposed guards for any disposal path that
  doesn't drain the worker first.

Adds IndexWorkerDisposalTest (reproduces the crash without the guards) and a
docs/process/learnings.md note on disposing the LSP test env inside a write
action.

Fixes APPDEVFORALL-17R

* fix: add defensive backstop in source file indexer

Signed-off-by: Akash Yadav <itsaky01@gmail.com>

* fix(ADFA-4384): address review — bound shutdown join, join fileAnalyzer, atomic disposal check

Follow-up to code review on the disposal fix:

- AbstractCompilationEnvironment.close(): runBlocking { ktSymbolIndex.close() }
  runs on the main thread during editor teardown; bound it with
  withTimeoutOrNull(CLOSE_DRAIN_TIMEOUT_MS) so a slow PSI read can't ANR. The
  project.isDisposed guards cover the rare timeout-before-drain case.
- CompilationEnvironment.close(): the fileAnalyzer scope was only cancel()'d
  (fire-and-forget) before disposal, so an in-flight collectDiagnosticsFor
  project.read could still touch a disposed project. Cancel AND join it
  (bounded) before super.close().
- SourceFileIndexer.indexSourceFile(): the project.isDisposed fast-path was
  non-atomic with toMetadata()'s internal project.read. Re-check disposal
  inside the read lock so the check and the PSI access are atomic.

All :lsp:kotlin unit tests pass (41, 0 failures).

---------

Signed-off-by: Akash Yadav <itsaky01@gmail.com>
Co-authored-by: Akash Yadav <itsaky01@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants