Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(jsii-pacmak): computeIfAbsent throws ConcurrentModificationException #1706

Merged
merged 6 commits into from
Jun 2, 2020

Conversation

NetaNir
Copy link
Contributor

@NetaNir NetaNir commented Jun 2, 2020


Java generated modules use cache to store the result of resolveClass which is called when ever jsiiStaticGet/jsiiGet is called. The cache is implemented as a HashMap, to fill the cache we use computeIfAbsent:

this.cache.computeIfAbsent(MODULE_TYPES.get(fqn), this::findClass);

code

When either of the get methods is called on a type which in itself includes a type, the first get (the root type get) will call get on the inner type. When computeIfAbsent is called from the root get, the compute method will trigger another call to computeIfAbsent to resolve the inner type, since all types share the same cache it means that the inner computeIfAbsent call will modify the cache while the root computeIfAbsent is not yet completed, resulting it a ConcurrentModificationException.

This effects all Java >= 9 applications as a change to the behavior of computeIfAbsent was introduced in Java 9. From the Javadocs:

Throws:
ConcurrentModificationException - if it is detected that the mapping function modified this map

The use of computeIfAbsent was introduced in #1605 and released in v1.4.1.

This PR replaces computeIfAbsent with explicitly checking if the value exists in the cache and if not adding it (essentially old style computeIfAbsent).

Test

Steps to verify fix:

  1. Build and package a sample module (see repro) using a local version of jsii and jsii-pacmak.
  2. Add the generated jar to a local mvn repo.
  3. Added the package as a dependency to a Java11 application.
  4. Verify the exception is no longer thrown.

Repro

A repro can be found in this repo

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

@mergify mergify bot added the contribution/core This is a PR that came from AWS. label Jun 2, 2020
@NetaNir
Copy link
Contributor Author

NetaNir commented Jun 2, 2020

copy @iliapolo

@aws-cdk-automation
Copy link
Collaborator

AWS CodeBuild CI Report

  • CodeBuild project: AutoBuildProject6AEA49D1-Blkkw9bQFn8A
  • Commit ID: d82887b
  • Result: FAILED
  • Build Logs (available for 30 days)

Powered by github-codebuild-logs, available on the AWS Serverless Application Repository

@NetaNir NetaNir self-assigned this Jun 2, 2020
@eladb
Copy link
Contributor

eladb commented Jun 2, 2020

🤩 wow

@aws-cdk-automation
Copy link
Collaborator

AWS CodeBuild CI Report

  • CodeBuild project: AutoBuildProject6AEA49D1-Blkkw9bQFn8A
  • Commit ID: 8fd896c
  • Result: FAILED
  • Build Logs (available for 30 days)

Powered by github-codebuild-logs, available on the AWS Serverless Application Repository

@aws-cdk-automation
Copy link
Collaborator

AWS CodeBuild CI Report

  • CodeBuild project: AutoBuildProject6AEA49D1-Blkkw9bQFn8A
  • Commit ID: 63d5d5d
  • Result: SUCCEEDED
  • Build Logs (available for 30 days)

Powered by github-codebuild-logs, available on the AWS Serverless Application Repository

@eladb
Copy link
Contributor

eladb commented Jun 2, 2020

@NetaNir What did we change that suddenly caused this? Can you please include a reference to the PR that introduced this break?

);
this.code.line('String className = MODULE_TYPES.get(fqn);');
this.code.openBlock('if (!this.cache.containsKey(className))');
this.code.line('this.cache.put(className, this.findClass(className));');
Copy link
Contributor

Choose a reason for hiding this comment

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

Any chance this can cause an infinite recursion (which I assume is why computeIfAbsent throws this error)?

Copy link
Contributor Author

@NetaNir NetaNir Jun 2, 2020

Choose a reason for hiding this comment

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

I think that there is a circuit breaker here, but I'll let @RomainMuller comment

Copy link
Contributor

Choose a reason for hiding this comment

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

I think this is going to be enough indeed. The problem was that further mutation to the map happened as part of this.findClass within the lambda expression passed to computeIfAbsent. This approach makes "atomic" mutations here (and this is safe because jsii apps can be expected to be single-threaded).

@NetaNir
Copy link
Contributor Author

NetaNir commented Jun 2, 2020

@NetaNir What did we change that suddenly caused this? Can you please include a reference to the PR that introduced this break?

@eladb updated description to include the PR which introduced the use of computeIfAbsent

@NetaNir NetaNir added the p0 label Jun 2, 2020
@eladb
Copy link
Contributor

eladb commented Jun 2, 2020

@NetaNir What did we change that suddenly caused this? Can you please include a reference to the PR that introduced this break?

@eladb updated description to include the PR which introduced the use of computeIfAbsent

Thanks!

Copy link
Contributor

@eladb eladb left a comment

Choose a reason for hiding this comment

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

Is there a way to add a unit test for this?

@rix0rrr
Copy link
Contributor

rix0rrr commented Jun 2, 2020

When computeIfAbsent is called from the root get, the compute method will trigger another call to computeIfAbsent to resolve the inner type,

We kinda knew that this would have to be the issue, but do we know HOW/WHY?

How in the hell does Class.forName(...) ultimately lead back to $Module.resolveClass()?


(I also originally expected $Module.resolveClass() to be in the stack trace twice, but of course that's not true. The ultimate complete stack trace would look like this:

(stack goes ⬇️ that way)
(...some stack frames...)
$Module.resolveClass
HashMap.computeIfAbsent      <----- 2️⃣ Error occurs here *AFTER* the frames below have unwound
$Module.findClass
Class.forName
(... what goes here...?)
$Module.resolveClass
HashMap.computeIfAbsent       <---- 1️⃣ This completes successfully first

But who can fill in the (... what goes here...) section for me?

@RomainMuller
Copy link
Contributor

@rix0rrr to answer your (... what goes here...) section:

  • Class.forName will possibly load up a newly discovered class (let's call it Foo)
    • Can be a base class/interface, some property's type, ...
  • Class initialization of Foo might involve jsii calls
    • Getting values of const fields, for example
  • The result of those calls may be something that $Module.resolveClass is used for (e.g: class/interface instance, struct, enum)
    • At this point, we'll get back to computeIfAbsent

@RomainMuller
Copy link
Contributor

I'm trying to come up with a test that blows up before the fix is introduced.

@rix0rrr
Copy link
Contributor

rix0rrr commented Jun 2, 2020

  • Getting values of const fields, for example

Got it. So that should be easy enough to write a test for then. So it's basically a matter of:

class SomeClass {
  public static const OTHER: OtherClass = new OtherClass();
}

?

@rix0rrr
Copy link
Contributor

rix0rrr commented Jun 2, 2020

Although of course that won't break if OtherClass is referenced in any other capacity...

@RomainMuller
Copy link
Contributor

  • Getting values of const fields, for example

Got it. So that should be easy enough to write a test for then. So it's basically a matter of:

Yeah that's what I thought but it appears the patch to trigger this situation depends on a bunch of other factors I'm trying to identify (possibly the properties being typed "any" or something.

@RomainMuller
Copy link
Contributor

Oh I missed a critical element of the repro: it only happens with Java >= 9. It seems related to a change in class loading behavior between 8 and 9+.

@iliapolo
Copy link
Contributor

iliapolo commented Jun 2, 2020

@RomainMuller Its not a change in class loading, its a change in the implementation of computeIfAbsent, which is Java >= 9 throws the ConcurrentModificationException when it detects a dirty mutation.

https://stackoverflow.com/questions/54824656/since-java-9-hashmap-computeifabsent-throws-concurrentmodificationexception-on

Regarding the repro, did you look at @NetaNir's repo?

It wasn't that straight-forward to reproduce, but I do think the repro can probably be simplified.

Also, since the recursion stems from Class.forName, it can only happen when a class has static initializers that eventually call jsiiGet - right? Since Class.forName only loads the class, and doesn't instantiate it.

@RomainMuller
Copy link
Contributor

So the flow is:

  • Resolving some class involves loading another
  • Loading the other class involves resolving yet another

An easy way to trigger is to have a const property causing loading of an enum (it'll then try to resolve the enum when loading up it's values)

Added a test to this effect. Interestingly, it works in Java 8, fails in Java 9. I suspect Java 9 resolves classes more eagerly than 8.

@iliapolo
Copy link
Contributor

iliapolo commented Jun 2, 2020

@RomainMuller

Interestingly, it works in Java 8, fails in Java 9. I suspect Java 9 resolves classes more eagerly than 8.

https://stackoverflow.com/questions/54824656/since-java-9-hashmap-computeifabsent-throws-concurrentmodificationexception-on

@aws-cdk-automation
Copy link
Collaborator

AWS CodeBuild CI Report

  • CodeBuild project: AutoBuildProject6AEA49D1-Blkkw9bQFn8A
  • Commit ID: ac7b8c7
  • Result: FAILED
  • Build Logs (available for 30 days)

Powered by github-codebuild-logs, available on the AWS Serverless Application Repository

@RomainMuller
Copy link
Contributor

@RomainMuller

Interestingly, it works in Java 8, fails in Java 9. I suspect Java 9 resolves classes more eagerly than 8.

https://stackoverflow.com/questions/54824656/since-java-9-hashmap-computeifabsent-throws-concurrentmodificationexception-on

Ohhhhh

@aws-cdk-automation
Copy link
Collaborator

AWS CodeBuild CI Report

  • CodeBuild project: AutoBuildProject6AEA49D1-Blkkw9bQFn8A
  • Commit ID: 38cde99
  • Result: SUCCEEDED
  • Build Logs (available for 30 days)

Powered by github-codebuild-logs, available on the AWS Serverless Application Repository

@mergify
Copy link
Contributor

mergify bot commented Jun 2, 2020

Thank you for contributing! ❤️ I will now look into making sure the PR is up-to-date, then proceed to try and merge it!

@mergify mergify bot added the pr/ready-to-merge This PR is ready to be merged. label Jun 2, 2020
@mergify mergify bot merged commit fa60b7f into master Jun 2, 2020
@mergify mergify bot deleted the neta/nested-get-fix branch June 2, 2020 11:28
@mergify
Copy link
Contributor

mergify bot commented Jun 2, 2020

Merging (with squash)...

@mergify mergify bot removed the pr/ready-to-merge This PR is ready to be merged. label Jun 2, 2020
@mergify
Copy link
Contributor

mergify bot commented Jun 2, 2020

Command refresh: success

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
contribution/core This is a PR that came from AWS. p0
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants