I'm looking into an odd IC issue someone reported ...
# compiler
z
I'm looking into an odd IC issue someone reported with Metro, where we do recompile and can look up a given recompiled class, but the class is missing all its declarations and annotations. Usually with IC it's a matter of linking two files/symbols together in compilation, but I've not encountered a situation where we get a symbol and it's just lossy. In this case, the symbol it's trying to read is in a separate upstream compilation ref: https://github.com/ZacSweers/metro/pull/1349
d
Does it reproduce stable? If yes, I could take a look tomorrow
z
Yup the test I linked above is on a stable 2.2.20 version and 100%
I can send you debugging info of where to look if you need too
d
Yeah, it would be nice.
Also, by chance do you have a version of the plugin compatible with something newer than 2.2.20?
z
yeah it's forward compatible to at least 2.3.0-Beta2
👍 1
So for the debugging • The
AppModule
class is looked up in
IrContributionData.findExternalBindingContainerContributions
• It is successfully found and returned by
findVisibleContributionClassesForScopeInHints()
, which looks up hints in a "known package" and derives contributing classes from this set • Then it passes those on to
getScopedContributions
and loses the class in filtering on this block
Copy code
contributions.filter { irClass -> with(metroContext) { irClass.isBindingContainer() } }
(line 116 in main) This filter fails because
isBindingContainer()
looks for a
@BindingContainer
annotation, and that's where I saw that the class is missing all annotations and member declarations. The class itself is defined in the test in
appModuleContent
and should look like so
Copy code
@BindingContainer
@ContributesTo(Unit::class)
interface AppModule {
  @Binds
  @IntoSet
  fun bindMultibinding(multibinding: AppMultibinding): Multibinding
}
one thing that sticks out to me is that this maybe be getting restored from a build cache entry and maybe that entry is missing data
because it does see everything in the first (successful) compilation
then the second compilation removes it, then the third compilation restores it exactly the same
I saw it by writing some metadata to metro's
IrMetroContext.writeDiagnostic
API since I'm not able to attach a debugger to the kotlinc process itself. If you breakpoint the gradle IC test though on the third compilation result, you can poke around that output directory (if you write anything) via
project.asMetroProject.appGraphReports.reportsDir
in the debugger
d
For the future notice, how to attach debugger to the kotlinc in your IC tests: 1. Add the following argument to gradle build
Copy code
val debugArg = """-Dkotlin.daemon.jvm.options="-agentlib:jdwp=transport=dt_socket\,server=n\,suspend=y\,address=5005""""
val buildResult = build(project.rootDir, "compileKotlin", debugArg)
2. Create Remote JVM debug run configuration and set it to Listen to remote JVM mode (I also recommend enabling Auto restart option) 3. Run this run configuration before tests 4. Run test (with or without debugger, doesn't matter)
🙇 1
During debugging I've discovered that
findExternalBindingContainerContributions
and
getScopedContributions
are not called on the third compilation. I don't have enough context, to understand where to move next, sorry. But I believe that you pick it up from here with these instructions. I'll be waiting for more inputs from you.
z
Thanks! I was seeing it travel through there I believed but with a debugger I should be able to get through here more easily
hmm, I'm not seeing that debugger config ever connect 🤔. Was there anything else you had to configure?
CleanShot 2025-11-13 at 13.20.47@2x.png,CleanShot 2025-11-13 at 13.20.55@2x.png,CleanShot 2025-11-13 at 13.21.08@2x.png
ohhh maybe it's just not recompiling at all and the gradle message was misleading
let me try an intermediate clean
hmmm nope
in the meantime, as best I can tell it's just not recompiling anything. The task is getting marked as
SUCCESS
but I can't find any evidence that the compiler is actually running vs just possibly restoring from cache (but with the second compilation's cache result and not the first)? It's hard to say 😕
yeah I'm increasingly of the impression this is a KGP caching issue with IC, as it seems to internally be be seeing a cache key hit for the second build even though a linked declaration in an upstream module has been restored. I can't think of any scenarios in the standard kotlin compilation that would encounter this though, it seems somewhat unique to Metro in that • both builds 1 and 2 are "successful", they just have different inputs • another scenario that would rely on linking otherwise absent-in-compiled-code types would be inline functions, but in that scenario #2 wouldn't succeed and thus produce a cache output I suspect I could retrigger this by doing some sort of reflection and triggering the same compilation avoidance, but that case would be expected to behave this way since the compiler doesn't know about the linking. In this case though, a symbol that was an input to a previous cache entry is now present again (but requires no source changes to the main source), unchanged, linked (importantly) in LookupTracker, and I suspect KGP isn't factoring that in and taking the first hit for its criteria (is the main file dirty, is anything it depends on dirty). CC @tapchicoma, let me know if this makes sense as a theory and if you want me to file an issue
or in less words, it seems LookupTracker isn't enough if KGP/IC thinks the callee file isn't dirty (independent of whether it was present in the previous build it's comparing to). IC seems to treat it as not dirty if the contents are the same, independent of presence/absence and LookupTracker linking when it was present), which would be surprising to me. Seems LookupTracker only considers what was linked in the previous compilation in this specific case, so new non-dirty symbols that would match don't get linked
d
Thank you for the investigation. Could you please extract the logic of what metro does and what changes in rounds in some minimalistic code example (even if it would be not entirely accurate)? It would be much easier to think of it in such a format.
z
lemme see what I can do
might be hard to decouple without just building a separate compiler plugin since this is tightly coupled to how metro works 😅
ok got a simple repro: https://github.com/ZacSweers/metro/commit/8adcbaaebfb18f2eeb4acc8ead8ad57cabd2df77 Has a test that mirrors the build scenario, an
ICReproTransformer
transformer that just swaps a function impl with some text based on whether a class it looks up from pluginContext is present or not (and links their compilations for IC). Test I think basically confirms my theory above too
🙇‍♂️ 1
🔥 1
d
The problem is actually on your side. In the
ICReproTransformer
you lookup for
Marker
class regardless of any conditions, but write lookup only if it was actually found. So if you replace
Copy code
if (markerClass != null) {
  trackClassLookup(collectorClass, markerClass)
}
with
Copy code
trackClassLookup(collectorClass, markerClassId)
where there is newly introduced
Copy code
context(context: IrMetroContext)
internal fun trackClassLookup(callingDeclaration: IrDeclaration, calleeClassId: ClassId) {
  callingDeclaration.withAnalyzableKtFile { filePath ->
    val container = calleeClassId.outerClassId?.asSingleFqName() ?: calleeClassId.packageFqName
    trackLookup(
      container = container,
      declarationName = calleeClassId.shortClassName.asString(),
      scopeKind = ScopeKind.PACKAGE,
      location =
        object : LocationInfo {
          override val filePath = filePath
          override val position: Position = Position.NO_POSITION
        },
    )
  }
}
the test will start passing.
Lookups in the frontend work in the same way:
Copy code
class A
fun A.foo() {}

fun test(a: A) {
    a.foo()
}
Here we resolve to function
ext A.foo
, but write lookups that we searched for
member A.foo
and
ext A.foo
. So if class
A
changes and the member
fun foo()
is added, the file with
test
functions becomes dirty, even if the extension function was unchanged.
z
hmm, that maybe fixes just the minimal repro but still seeing the same issue with the original test unfortunately, even with that change. We do always record a lookup of the class when it's present, I see the lookup present for the first compilation (of the original repro) and then not in the second compilation after it's deleted, then the third compilation skips recompiling the consuming class
I think your change for the repro test works just because we always record the lookup even if it doesn't exist in that case, but that's only possible if you know the classid ahead of time. The test was set up to mimic that behavior. Am I missing something?
d
but that's only possible if you know the classid ahead of time
How do you get to know class id in the real case? Using lookup predicate?
We discussed this issue internally, and it seems that the lookup to the hint function is missing. Me or Brian could setup a debug session with you to find the root of the problem.
z
That’d be great. Let me try Brian first since he’s in my time zone
👍 1
@bnorm and I paired for a bit today and figured it out! The short explanation is • Metro generates top-level hint functions to a known
metro.hints
package, in the shape of
fun AppScope(source: SourceClass)
• In IC, we were seeing that
SourceClass
was being marked as dirty when it was restored (correct) but its generated hint was not, and thus was also being kept from the
main
compilation after it • main compilation looks for that hint to then look up SourceClass • Fix was to track a lookup from the generated hint function to the SourceClass parameter so they'd get marked as dirty together
🙇‍♂️ 1
🔥 4