Skip to content

ADFA-5053: Lazy-load the embedded javac fork via a DexClassLoader carrier APK - #1638

Open
davidschachterADFA wants to merge 23 commits into
stagefrom
task/ADFA-5053-lazy-load-javac-carrier
Open

ADFA-5053: Lazy-load the embedded javac fork via a DexClassLoader carrier APK#1638
davidschachterADFA wants to merge 23 commits into
stagefrom
task/ADFA-5053-lazy-load-javac-carrier

Conversation

@davidschachterADFA

Copy link
Copy Markdown
Collaborator

Summary

ADFA-5053: moves the embedded javac/jdk-compiler fork (~2,238 classes, ~3.7MB, the #2 DEX-bloat contributor per ADFA-4549) off app's main DEX, mirroring ADFA-5010's Kotlin Analysis API treatment (PR #1635, ADR 0011).

  • New modules: lsp/java-api (resident bridge interface), lsp/java-compiler-impl (isolated payload -- the actual javac-dependent code), subprojects/javac-fs (resident file/attribute-caching leaf classes subprojects/projects needs for every project regardless of language), subprojects/java-compiler-carrier (the never-installed carrier APK).
  • JavaLanguageServer becomes a thin wrapper; javac is loaded via DexClassLoader on first real .java-file interaction, reusing ADFA-5052's CompilerLifecycle/ensureProjectReset() trigger machinery rather than rebuilding it.
  • Full design, the vendored-composite-build relocation, and two hazard classes found along the way (duplicate-class-identity from stray api() deps in vendored build.gradle.kts files; cross-classloader protected/package-private access throwing IllegalAccessError at runtime) are documented in ADR 0012.

Stacked on #1637 (ADFA-5052) -- this branch is built on top of it and includes its commits, since ADFA-5053 reuses its lazy-trigger machinery rather than duplicating it. The diff here will shrink to just this ticket's commits once #1637 merges to stage.

Test plan

  • spotlessCheck clean, module compiles (:lsp:java-compiler-impl, :lsp:java, :lsp:java-api)
  • Relocated lsp/java unit tests moved into lsp/java-compiler-impl's own test sourceset
  • On-device manual verification (physical device): carrier extracts and DexClassLoader-loads on first .java file interaction; completion, diagnostics, navigation, and code actions work; zero IllegalAccessError across two independent app-restart/retest cycles after the cross-classloader access fix
  • Architecture review against ARCHITECTURE.md + ADRs 0003/0005 -- module boundaries, ABI flavors, and dependency-substitution discipline all check out
  • Release-build DEX size verification (apkanalyzer dex packages before/after) -- not yet run in this session

🤖 Generated with Claude Code

davidschachterADFA and others added 14 commits August 6, 2026 17:53
CacheFSInfo/FSInfo/RelativePath/Context/PlatformUtils/Assert (package
unchanged) need to stay resident so subprojects/projects can keep using
them for classpath-jar indexing, while the rest of jdk-compiler's ~400
files (parser/Attr/Resolve/Symtab/Types/codegen) move into an isolated,
DexClassLoader-loaded carrier in a later commit.

Mechanical move only: jdk-compiler already depends on java-compiler
(api(projects.buildDeps.javaCompiler)), so relocating files the other
direction can't create a cycle, and every dependency of these six
classes (java.util/nio, jdkx.tools.JavaFileObject, zipfs2's
AndroidFsProvider) is already satisfiable from java-compiler alone.
Without this move, isolating the rest of jdk-compiler would either
duplicate these classes (a resident copy plus a carrier-dex copy) or
strand them where subprojects/projects can't reach them.

Verified :build-deps:java-compiler, :build-deps:jdk-compiler,
:subprojects:javac-services, :subprojects:projects, and :lsp:java all
still compile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
subprojects/projects (the foundational, always-resident project-model
module used by every project regardless of language) depends on
javac's file-system caching wrappers (CacheFSInfoSingleton,
CachedJarFileSystem, CachingJarFileSystemProvider, JarPackageProviderImpl,
AndroidFsProviderImpl) for live classpath-jar indexing. That has to stay
resident even after the rest of javac's fork moves into an isolated
DexClassLoader carrier in a later commit, so it needs its own module
rather than living inside javac-services (which is becoming the heavy,
isolated payload).

Package name (com.itsaky.androidide.javac.services.fs) is unchanged, so
no source outside build.gradle.kts files needed touching. javac-services
depends on it directly now (previously that was implicit, via the two
sharing one module); subprojects/projects and lsp/java depend on it
directly too instead of transitively through javac-services.

Verified :subprojects:javac-fs, :subprojects:javac-services,
:subprojects:projects, and :lsp:java all compile, and existing unit
tests for :subprojects:projects and :subprojects:javac-services pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ntil a real .java file is touched

DefaultLanguageServerRegistry.onProjectInitialized dispatches setupWithProject
to every registered language server unconditionally, regardless of project
language. JavaLanguageServer.setupWithProject() referenced
JavaCompilerService.NO_MODULE_COMPILER and called SourceFileManager.clearCache(),
both of which trigger class-init that eagerly constructs real javac Context/
JavacFileManager machinery plus a full android.jar top-level-class scan --
on the first project open in the app's lifetime, Kotlin-only projects included.
shutdown() had the same problem in reverse, on every project close.

Same eager-load bug pattern ADFA-5010 fixed for the Kotlin Analysis API, and
independently confirmed and sized (openjdk.tools.javac ~2,238 classes, ~3.7MB)
while researching whether javac could get ADFA-5010's carrier-APK treatment.
This fix is scoped to just the eager-construction bug -- no DexClassLoader/
carrier-APK split; javac/jdk-compiler stay in the main dex, just constructed
lazily.

setupWithProject() now only stashes the workspace; the actual reset (destroy
NO_MODULE_COMPILER, clear file-manager/JAR-fs caches, index module classpaths)
is deferred to ensureProjectReset(), called from getCompiler() and
onContentChange() -- both already gated on DocumentUtils.isJavaFile(), so
this now only runs on genuine Java-file interaction. shutdown() skips its
javac-specific cleanup entirely if that never happened. Per-file LSP dispatch
methods (complete/findReferences/findDefinition/expandSelection/signatureHelp)
needed no changes: the editor's IDELanguage already resolves one language
server per file before calling any of them, so they were never the source of
the cross-language trigger.

Verified: :lsp:java unit tests pass, :app:assembleV8Debug builds clean.
…ss and shutdown

The previous fix's synchronized(this) block only guarded the *decision* to
run ensureProjectReset() (claiming pendingWorkspace, flipping
javaCompilerInitialized to true) -- not the destroy/rebuild work that
followed, which ran unsynchronized. A concurrent getCompiler()/
onContentChange() call on another thread could see javaCompilerInitialized
already true and proceed to use JavaCompilerProvider/SourceFileManager while
the first thread was still mid-destroy or mid-rebuild. shutdown() didn't
synchronize on anything at all, so it could run its own destroy()/
clearCache() concurrently with an in-flight reset, racing two teardown/
rebuild sequences against each other.

Replaces the two ad-hoc @volatile fields with an explicit
PENDING/RESETTING/INITIALIZED/SHUTDOWN state machine guarded by a single
ReentrantLock (compilerLifecycleLock) held for the *entire* reset or
shutdown, not just the state transition. Concurrent callers now genuinely
block until an in-flight reset finishes (getCompiler()/onContentChange()
already route through ensureProjectReset(), which now can't return early
while another thread holds the lock), and shutdown() waits on the same
lock before deciding whether there's anything to tear down. setupWithProject()
also goes through the lock; if a new project arrives mid-reset, the
in-progress reset's own finally block detects the newer pendingWorkspace and
reverts to PENDING instead of incorrectly claiming INITIALIZED.

Verified: :lsp:java unit tests pass, :app:assembleV8Debug builds clean.
…t-lock race

Three issues from /code-review high, verified against the current code:

1. ensureProjectReset() nulled pendingWorkspace before the try block, so any
   exception during destroy/rebuild (e.g. a bad submodule's classpath) still
   let the finally claim INITIALIZED -- silently treating a half-torn-down
   compiler as ready, with no retry, for the rest of the session. Now an
   exception re-queues the workspace, reverts to PENDING, and rethrows.

2. analyze() never called ensureProjectReset() at all. diagnosticProvider
   .analyze() builds its own JavaCompilerService directly, bypassing
   getCompiler(), and analysis is often the *first* real .java-file
   interaction (auto-triggered on file open, ahead of any completion
   request) -- so the R.jar/file-manager cache clear this reset performs
   could be skipped for an entire session, leaving diagnostics resolving
   against a stale previous project's classpath. Now gated the same way
   getCompiler()/onContentChange() already are.

3. getCompiler() and onContentChange() released compilerLifecycleLock as
   soon as ensureProjectReset() returned, then used JavaCompilerProvider
   unlocked -- a concurrent reset for a newer project could destroy() those
   compilers in the gap. Both now hold the lock across the reset and the
   subsequent provider lookup/use (safe: ReentrantLock is reentrant, so
   ensureProjectReset()'s own internal withLock nests without deadlocking).

Two other findings from the same pass were assessed and left as-is:
- shutdown() blocking on an in-flight reset with no cancellation is real but
  performance-only (no crash/corruption), requires disproportionate
  cancellation plumbing through SourceFileManager/JavaCompilerService for a
  narrow, bounded-cost edge case.
- KotlinLanguageServer's eager construction is a real observation about this
  branch's current state, but it's already fixed by the separate, not-yet-
  merged ADFA-5010 (PR #1635) -- out of scope here, not a gap in this PR.

Verified: :lsp:java unit tests pass, :app:assembleV8Debug builds clean.
New IJavaCompilerSession/IJavaCompilerSessionFactory interfaces,
mirroring lsp/kotlin-api's role from ADR 0011: this is the only type
surface a resident JavaLanguageServer will be allowed to reference
once JavaCompilerService and its Provider classes move into an
isolated, DexClassLoader-loaded module in a later commit.

Exposes the LSP operations directly (complete/findReferences/
findDefinition/expandSelection/signatureHelp/analyze/onContentChange)
rather than a getCompiler(): JavaCompilerService accessor, since
JavaCompilerService itself won't be a resident type. Confirmed no
caller outside JavaLanguageServer.kt uses the current getCompiler()
(it's @RestrictTo(LIBRARY_GROUP)), so dropping it from the bridge is
safe.

Not yet wired up -- JavaLanguageServer.kt still talks to the
soon-to-be-isolated types directly; that happens once the isolated
implementation module exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…-impl

Moves the whole javac-touching surface of lsp/java (compiler/, providers/
except providers/snippet, actions/, edits/, parser/, rewrite/, utils/
except AnalyzeTimer, visitors/, JavaCompilerProvider, and their models)
into a new isolated module. JavaLanguageServer.kt stays in lsp/java as a
thin resident wrapper: it keeps ADFA-5052's CompilerLifecycle state
machine unchanged, but ensureProjectReset() now lazily extracts and
DexClassLoader-loads a carrier APK (via the new JavaCompilerLoader,
mirroring KotlinCompilerLoader/ADR 0011) instead of directly constructing
JavaCompilerService/JavaCompilerProvider. Every LSP operation
(complete/findReferences/findDefinition/expandSelection/signatureHelp/
analyze/onContentChange/formatCode/handleFailure) now delegates through
the new IJavaCompilerSession bridge (lsp/java-api) instead of touching
isolated types directly.

Not yet wired to an actual carrier: no carrier module exists yet, so
JavaCompilerLoader has nothing to load on-device. That's the next
commit. Locally, :lsp:java-compiler-impl:compileV8DebugSources verifies
the isolated payload builds standalone.

Notable fixes needed along the way, all confirmed via compilation
across :app, :editor, :lsp:java, and :lsp:java-compiler-impl:

- google-java-format stays a resident dependency (JavaServerSettings'
  formatter options) rather than moving with javac -- ADFA-4549 never
  flagged it as bloat, and the isolated module sees it via compileOnly
  so the type identity still matches.
- The debugger's breakpoint/stack-frame source-path resolution
  (debug/utils/ModelUtils.kt) took a real, if narrow, dependency on
  JavaCompilerProvider/SourceFileObject. Added
  IJavaCompilerSession.findSourceFilePath so it resolves through the
  bridge (returning a plain path, not the isolated SourceFileObject
  type) instead of needing the isolated module directly.
- CancelChecker.kt and CompletableDeferredExts.kt were misplaced under
  lsp/java/utils despite being genuinely generic (editor's IDEEditor.kt
  and app's ProjectHandlerActivity.kt use the former for coroutine
  cancellation logging unrelated to javac; app's Resolvable.kt uses the
  latter for Deferred completion state). CancelChecker moved with the
  isolated payload (its CancelAbort check is genuinely javac-specific
  and classloader-identity-sensitive); the two generic call sites got
  their own small inline cancellation check instead.
  CompletableDeferredExts.kt moved back to stay resident.
- Applied ADFA-5010's LSPEditorActions fix (replace-not-skip on
  register, plus a new unregisterCodeActions) here too: Java's own
  carrier needs the same protection against a stale session's actions
  outliving its DexClassLoader.
- Moved the lsp/java unit tests that exercised isolated types
  (JavaCompilerService, CompletionProvider, JavaSelectionProvider,
  DefinitionProvider, etc.) into lsp/java-compiler-impl's own test
  sourceset, and rewrote their `server.<lspMethod>()` calls to
  construct the isolated providers directly -- going through
  JavaLanguageServer would now try to load a real carrier APK that
  doesn't exist in the unit test environment.

Verified: :app, :editor, :lsp:java, :lsp:java-compiler-impl,
:subprojects:projects all compile; unit tests pass for :lsp:java,
:lsp:java-compiler-impl, and :subprojects:projects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…lass leaks it exposed

New subprojects/java-compiler-carrier: a never-installed com.android.application
shell (isMinifyEnabled=false, mirrors subprojects/kotlin-compiler-carrier) that
exists only so AGP produces a real classes.dex from lsp:java-compiler-impl, for
JavaCompilerLoader to DexClassLoader at runtime.

Assembling it for the first time surfaced that the isolation from the previous
commits wasn't actually leak-proof -- verified with dexdump against both the
carrier's and the main app's dex, not just by reading the Gradle config:

- jdk-compiler's own build.gradle.kts had `api(projects.buildDeps.javaCompiler)`
  -- an api dependency propagates to every consumer's runtime/packaging
  classpath regardless of how *they* declare their own dependency on
  jdk-compiler, so no amount of compileOnly on the consumer side could stop
  java-compiler (CacheFSInfo, Context, etc.) from being duplicated into the
  carrier. Changed to compileOnly; the javac aggregate module still api's both
  itself for its own resident-only consumers.
- lsp-java-compiler-impl itself directly depended on the libs.composite.javac
  aggregate (both jdk-compiler and java-compiler) instead of jdk-compiler
  alone -- switched to the split dependency.
- javac-services depended on javac-fs via implementation instead of
  compileOnly, bundling the resident fs wrappers into the carrier too.
- google-java-format's own build.gradle.kts had the same api(javac aggregate)
  problem as jdk-compiler -- fixed the same way. But google-java-format
  actually runs javac's real parser at runtime to reformat source, so unlike
  javapoet it has to move with javac into the isolated module, not stay
  resident: JavaServerSettings (resident) now exposes only a plain code-style
  int instead of a google-java-format JavaFormatterOptions/Style value, and
  the two isolated call sites (CodeFormatProvider, OrganizeImportsAction)
  build the real options object themselves.
- javapoet, by contrast, turned out to be needed unconditionally and
  resident-side too (templates-api/templates-impl's "New Project" wizard, a
  completely separate use from the Java LSP's code-generation actions) and is
  lightweight (no jdk-compiler dependency at all) -- kept it fully resident,
  with lsp-java-compiler-impl seeing it via compileOnly.
- app/build.gradle.kts had a stray direct `implementation(projects.subprojects
  .javacServices)` with zero actual source usage in app/ -- the same class of
  leftover ADFA-5010 found and removed for kotlin-analysis-api. This was the
  very last leak keeping the full heavy javac fork in the main app's dex even
  after every other fix above.

Verified end-to-end via dexdump class-descriptor listings (not just
`checkDuplicateClasses`, which only catches conflicts within one module's own
build): CacheFSInfo/Context/Assert/PlatformUtils/CacheFSInfoSingleton/JavaPoet
appear exactly once, in the main app's dex, never the carrier's; NBAttr/
ReusableCompiler/JavaCompilerService/JavaCompilerSessionImpl/google-java-
format's Formatter appear exactly once, in the carrier's dex, never the main
app's. :app:assembleV8Debug and :subprojects:java-compiler-carrier:assembleV8Debug
both succeed; :lsp:java, :lsp:java-compiler-impl, :subprojects:projects, and
:subprojects:javac-services unit tests all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New copyJavaCompilerCarrierToAssets task (app/build.gradle.kts), mirroring
ADR 0011's copyKotlinCompilerCarrierToAssets pattern: builds
:subprojects:java-compiler-carrier:assembleV8Release and copies the unsigned
APK to app/src/main/assets/data/common/java-compiler-carrier.apk, wired into
preBuild so it's always current. No PNG-optimization step needed here (unlike
the Kotlin carrier) -- this module has no resources at all.

Includes the same evaluationDependsOn(":subprojects:java-compiler-carrier")
workaround the Kotlin carrier needed: without it, configureondemand=true only
reaches that (com.android.application) project lazily via the task's
cross-project dependsOn, which trips a "DefaultClassLoaderScope must be
locked" Gradle failure specific to that project type.

Verified: :app:copyJavaCompilerCarrierToAssets and :app:assembleV8Debug both
succeed, and the built APK's assets/data/common/java-compiler-carrier.apk
(~36MB) is a raw asset entry, not merged into app's own classes*.dex.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
-keep class openjdk.** { *; } is a no-op now that the javac fork lives only
in the isolated carrier's dex, never app's own -- confirmed via a real
:app:assembleV8Release build, dexdump-verified before and after: zero
Lopenjdk/** class descriptors were reachable-but-for-the-rule, and the small
resident set (CacheFSInfo/FSInfo/RelativePath, kept alive by genuine
reachability from subprojects/projects, not this rule) survives R8 shrinking
identically with the rule removed. jdkx.**'s keep rule stays -- unlike
Kotlin's Analysis API, some jdkx/java-compiler classes remain genuinely
resident (javac-fs, javapoet), so removing it isn't a pure no-op the way this
one was.

ADR 0012 documents the full decision: the ADR 0011 precedent this mirrors,
the subprojects/projects coupling that made this harder than Kotlin's case
and how the vendored-source relocation resolved it, the google-java-format
-vs-javapoet resident/isolated judgment calls, and the three real
duplicate-class-identity bugs (an `api` dependency in a vendored composite
build's own build.gradle.kts propagating to every consumer regardless of how
they declare their own dependency) found only by dexdumping the actual built
carrier and app dex, not by reading the Gradle config.

Verified: :app:assembleV8Release succeeds; the app launches without
crashing on a physical device (Pixel 6 Pro) with the rule removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…s to public

Found via the interactive on-device pass (not by the build or unit tests):
opening a real .java file crashed with

  IllegalAccessError: Method 'java.util.Optional openjdk.tools.javac.file.
  CacheFSInfo.getAttributes(java.nio.file.Path)' is inaccessible to class
  'openjdk.tools.javac.file.JavacFileManager' (declaration of
  'JavacFileManager' appears in .../java-compiler-carrier.apk!classes4.dex)

ART treats two classes with the identical package name as different runtime
packages when they're loaded by different classloaders -- same-package and
protected access are resolved by classloader identity, not just the package
string. CacheFSInfo/RelativePath are resident (java-compiler, per ADR 0012);
JavacFileManager is isolated in the carrier (jdk-compiler). Calling a
protected or package-private member across that boundary throws
IllegalAccessError at runtime, with no build-time or unit-test signal at all.

Widened the three members JavacFileManager/JRTIndex actually call this way:
CacheFSInfo.getAttributes, RelativePath.RelativeFile.forClass, and
RelativePath.RelativeDirectory.forPackage. Audited the rest of jdk-compiler
for other cross-boundary protected/package-private accesses on the six
resident leaf classes (CacheFSInfo, FSInfo, Context, Assert, PlatformUtils,
RelativePath) via static call-site search -- no others found.

Verified live on a physical device (Pixel 6 Pro): opening a real .java file
now extracts and DexClassLoader-loads the carrier, constructs
SourceFileManager, and runs a full diagnostic pass to completion with zero
IllegalAccessError and zero crashes, across two clean app restarts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ADR 0012

Records the IllegalAccessError finding from the on-device verification pass
as its own hazard class, distinct from the duplicate-class-identity one
already documented: ART resolves same-package/protected access by
classloader identity, not just the package name string, so a protected or
package-private member on a resident class throws IllegalAccessError when
called from isolated code, with zero build-time or unit-test signal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Spotless ratchet is file-level: since ~150 files were git-mv'd into
the new lsp/java-compiler-impl module, every one of them differs from
origin/stage and gets reformatted in full on first touch. Run
spotlessApply and manually fix the handful of ktlint violations it
couldn't auto-correct (wildcard import, duplicate license header,
comment placement, mixed &&/||, a var that should've been val, and
lines pushed over the 140-col limit by tab-width expansion).
The ADR said all three of jdk-compiler/javapoet/google-java-format needed
their api() dependency on java-compiler fixed to compileOnly, but the
actual diff deliberately left javapoet's api() unchanged (correctly --
javapoet stays fully resident, so it has no isolated consumer to leak
into). Only jdk-compiler and google-java-format needed the fix. Caught
by an architecture-review pass before opening the PR.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

…rrier

Both were declared implementation instead of compileOnly, duplicating these
resident libraries -- including native .so payloads -- into the isolated
carrier dex alongside the identical resident copies, defeating part of the
dex-size point of the isolation and risking UnsatisfiedLinkError if both
classloaders load the same-named .so. The carrier's DexClassLoader resolves
them from its parent (the resident classloader) instead.

Also adds kotlinx-coroutines-core as compileOnly, needed by a follow-up
commit that translates CancelAbort into CancellationException before it
crosses back out of the isolated session.
Six independent issues found during code review of the javac carrier split:

- JavaCompilerImpl.parse() closed the TSParseResult it got from
  TSJavaParser's own LRU cache via .use{} -- since the cache returns the
  same instance on a hit, this double-closed (and use-after-freed) the
  underlying native parse tree on the next completion request for an
  unchanged file. Stop closing it; the cache already owns its lifecycle.

- JavaLanguageServer.complete()/formatCode() resolved the compiler session
  and released compilerLifecycleLock before using it, unlike
  onContentChange() -- a concurrent project reset could destroy() the
  session's compilers in the gap. Now hold the lock across both steps, same
  as onContentChange(). The five suspend methods (findReferences etc.)
  can't use the same fix: the Kotlin compiler rejects a suspension point
  inside a lock's critical section outright. Documented why, since the
  residual race there is narrower than it looks (JavaCompilerProvider's
  map access is already synchronized).

- JavaLanguageServer.shutdown() gated its teardown on
  compilerLifecycle == INITIALIZED, but setupWithProject() sets state back
  to PENDING on every project switch even once the carrier's already
  loaded -- a switch queued without a .java-file interaction yet left a
  live session's DexClassLoader leaked. Gate on session existence instead.

- JavaCompilerLoader.close() mutated the session field without
  synchronized(this), unlike getOrCreateSession(), risking a race between
  the two.

- JavaCompilerSessionImpl.close() never destroyed NO_MODULE_COMPILER,
  unlike resetProject() -- now each session owns its own discardable
  DexClassLoader, so nothing could reach back to release it once replaced.

- CodeActionsMenu.children was a plain, unsynchronized LinkedHashSet
  mutated from LSP-dispatch threads (session register/unregister)
  concurrently with the UI thread rendering the menu. Switched to
  CopyOnWriteArraySet.

- CachedJarFileSystem.packages was a plain, unsynchronized map written by
  resident classpath indexing and read by the isolated compiler through
  the same shared provider -- now crossing the classloader boundary too,
  not just threads. Switched to ConcurrentHashMap.

- javac's cancellation signal, CancelAbort, is thrown deep inside the
  isolated fork and is only classloader-identity-safe to recognize on the
  side that threw it -- IDEEditor's isCancelled() check (which looks for
  CancellationException) silently stopped recognizing it once javac moved
  into the carrier, logging routine cancellations as real failures.
  JavaCompilerSessionImpl now translates CancelAbort into
  CancellationException before it crosses back to resident code.
- SourceFileObject.equals() compared paths via the live Files.isSameFile()
  check while hashCode() hashed the raw Path, violating the equals/hashCode
  contract -- two objects Files.isSameFile() considered equal (e.g. a
  symlink, or a relative vs. canonicalized path) could land in different
  hash buckets, silently breaking the compile-cache map's lookups. Both now
  key off a single canonicalPath resolved once via toRealPath().

- MultipleClassImportEditHandler computed each new import's position
  independently against the same pre-edit AST, then applied them in
  sequence against the same live buffer -- an earlier insertion shifts
  every line after it, invalidating a later edit's pre-computed position.
  Apply bottom-to-top instead: inserting at a lower line never shifts
  anything above it.

- Four unguarded edge cases in new rewrite handlers that threw instead of
  cancelling gracefully: GenerateRecordConstructor NPEs on an unresolvable
  type element/tree; CreateMissingMethod threw when a call sat in a field
  or static initializer (no enclosing method) and StringIndexOutOfBounds
  on an anonymous class's empty simple name; RemoveException read one past
  the end of the buffer when a trailing comma was its last character.

- ModelUtils.asLspLocation()'s fallback treated JDI's sourcePath() (a
  package-relative string) as a real filesystem path when no compiler
  session was available yet -- e.g. the very first breakpoint hit in a
  session, before any .java file had loaded the carrier -- so File(path)
  silently failed to open. Resolve it against each module's compile source
  directories first, which works without a session; log clearly if that
  also fails instead of silently handing back an unusable path.
- JavaCompletionProviderTest's members() was missing its @test annotation,
  so JUnit silently skipped it -- any regression in that completion path
  would have passed CI unnoticed.

- No test exercised JavaCompilerLoader at all. Added coverage for
  close()/currentSession()'s contract when no session was ever created.
  This does not cover the getOrCreateSession()-vs-close() race the
  synchronization fix in this branch addresses, or JavaLanguageServer's
  CompilerLifecycle state machine more broadly: getOrCreateSession()
  extracts a real carrier APK and DexClassLoader-loads it, neither of
  which works in this JVM unit-test environment (no carrier APK asset is
  present, and there's no on-device ART to load it into) -- the same
  constraint that made JavaCompletionProviderTest bypass JavaLanguageServer
  entirely. Closing that gap needs either a DI seam for the classloader
  construction or an on-device instrumented test.
…Context

ADR 0012 and JavaCompilerLoader's doc comment cited ADR 0011 and
KotlinCompilerLoader as already-established precedent ("mirrors ADR 0011
exactly"), but neither exists on this branch: ADFA-5010 (the sibling
ticket that introduces them) is a separate, unmerged branch. Added a note
clarifying the real relationship and pointing at the actual existing
precedent both tickets extend, PluginLoader.

Also documents why ReusableContext extends Context (isolated extending
resident) accessing its protected ht/key() members doesn't need the same
public-widening treatment ADR 0012 already applied to three sibling-access
cases: protected access via inheritance is governed by a different JVMS
5.4.4 rule than same-package-sibling access, with no runtime-package/
classloader-identity condition attached. No code change -- confirmed safe,
not a bug.
…ppdevforall/CodeOnTheGo into task/ADFA-5053-lazy-load-javac-carrier
CI's "Build Universal APK" check has failed 3/3 times on this branch
(before and after this batch of fixes) with:

  Property '$1' specifies file '.../java-compiler-carrier-v8-release-unsigned.apk'
  which doesn't exist.

Reproduced locally: packageV8Release completes and reports success, but
the plain APK file is intermittently missing from disk immediately
afterward. The task only had dependsOn(":...:assembleV8Release") -- a
task-ordering hint, not a real value-based dependency -- wired to a
hardcoded path guessing the AGP-produced filename ("-unsigned" suffix
included). That's exactly the shape of bug that races the file's own
write-to-disk on some environments even though dependsOn ordering is
satisfied.

Fixed by exposing the release variant's real APK output directory via
AGP's variant artifacts API (variant.artifacts.get(SingleArtifact.APK))
instead of a hardcoded path -- this ties Gradle's dependency tracking to
the actual producing task's Provider, and also stops assuming the
"-unsigned" filename, which was never guaranteed to stay accurate.
Verified with 5 consecutive clean (--no-build-cache --rerun-tasks)
local rebuilds, plus a full :app:assembleV8Debug sanity build.
@appdevforall appdevforall deleted a comment from coderabbitai Bot Aug 8, 2026
…ava-compiler-impl

androidx.annotation, guava, gson, androidx.core.ktx, and kotlin-stdlib
were all `implementation` despite every one already being resident
(loaded by the parent classloader by the time the carrier's
DexClassLoader runs) -- the same pattern already used correctly for
androidide.ts/common.editor two lines above. Changed all five to
compileOnly.

Verified via a clean rebuild that this alone doesn't shrink
java-compiler-carrier.apk: androidx.core's resources and the other
four libraries also arrive through implementation(javacServices),
which pulls in :common (and guava, kotlin-stdlib) on its own account,
independent of what this module declares directly. That's a separate,
deeper fix blocked by an AGP consistent-classpath conflict -- tracked
as its own follow-up. This change is still correct and harmless on
its own terms; it just isn't where the carrier's bytes are.
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.

1 participant