Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 52 additions & 13 deletions eo-runtime/src/main/java/org/eolang/AtWithRho.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,13 @@

package org.eolang;

import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;

/**
* The attribute that tries to copy object and set \rho to it if it has not already set.
* This attribute is NOT thread safe!
*
* @since 0.36.0
* @todo #4673:30min The {@link AtWithRho#get()} is not thread safe. If multiple threads
* call get() concurrently when the underlying object lacks RHO, each thread will:
* 1. Pass the !ret.hasRho() check
* 2. Create its own copy via ret.copy()
* 3. Attempt to set RHO on its copy
* This results in different threads receiving different copies, violating the expectation
* that get() returns a consistent view of the attribute's value.
*/
final class AtWithRho implements Attr {
/**
Expand All @@ -29,6 +24,16 @@ final class AtWithRho implements Attr {
*/
private final Phi rho;

/**
* Cached result of {@link #get()} to guarantee a consistent view across threads.
*/
private final AtomicReference<Phi> cached;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Lock guarding the first initialization of {@link #cached}.
*/
private final ReentrantLock lock;

/**
* Ctor.
* @param attr Attribute
Expand All @@ -37,6 +42,8 @@ final class AtWithRho implements Attr {
AtWithRho(final Attr attr, final Phi rho) {
this.original = attr;
this.rho = rho;
this.cached = new AtomicReference<>();
this.lock = new ReentrantLock();
}

@Override
Expand All @@ -49,16 +56,48 @@ public Attr copy(final Phi self) {

@Override
public Phi get() {
Phi ret = this.original.get();
if (!ret.hasRho()) {
ret = ret.copy();
ret.put(Phi.RHO, this.rho);
final Phi ret = this.cached.get();
if (ret != null) {
return ret;
}
return ret;
return this.initialize();
}

@Override
public void put(final Phi phi) {
this.cached.set(null);
this.original.put(phi);
Comment on lines 68 to 70
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.

⚠️ Potential issue | 🔴 Critical

Synchronize cache invalidation with initialization.

put() clears cached without the get() lock. A racing get() can compute an old value, put() can clear/update, then get() can write the stale value back into cached. Use the same lock around invalidation and delegation.

🔒 Proposed fix: serialize `put()` with `get()` initialization
     `@Override`
     public void put(final Phi phi) {
-        this.cached.set(null);
-        this.original.put(phi);
+        this.lock.lock();
+        try {
+            this.cached.set(null);
+            this.original.put(phi);
+        } finally {
+            this.lock.unlock();
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@eo-runtime/src/main/java/org/eolang/AtWithRho.java` around lines 80 - 82,
put() clears cached without holding the same lock used by get(), allowing a race
where get() recomputes and rewrites a stale value; fix by wrapping the
invalidation and delegation inside the same synchronization used by get() (i.e.,
use the same lock object/monitor that guards cached initialization in get()) —
for example, place cached.set(null) and original.put(phi) inside a
synchronized(...) block using the lock object referenced in get() so put() is
serialized with get()'s initialization.

}

/**
* Initialize the cached value under the lock, using double-checked locking.
* @return Cached {@link Phi}
*/
private Phi initialize() {
this.lock.lock();
try {
Phi ret = this.cached.get();
if (ret == null) {
ret = this.withRho(this.original.get());
this.cached.set(ret);
}
return ret;
} finally {
this.lock.unlock();
}
}

/**
* Return the given object with {@link Phi#RHO} attached, copying it if needed.
* @param phi Original object
* @return Object with {@code \rho} set
*/
private Phi withRho(final Phi phi) {
if (phi.hasRho()) {
return phi;
}
final Phi copy = phi.copy();
copy.put(Phi.RHO, this.rho);
return copy;
}
}
50 changes: 50 additions & 0 deletions eo-runtime/src/test/java/org/eolang/AtWithRhoTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
*/
package org.eolang;

import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -97,4 +102,49 @@
Matchers.not(Matchers.is(obj))
);
}

@Test
void returnsSameInstanceToConcurrentCallers() throws InterruptedException {
MatcherAssert.assertThat(
"AtWithRho.get() must return the same instance for all concurrent callers",
AtWithRhoTest.collectConcurrentGet(
new AtWithRho(
new AtComposite(new PhDefault(), phi -> phi),
new PhDefault()
),
16
),
Matchers.hasSize(1)
);
}

/**
* Invoke {@link Attr#get()} concurrently from many threads released
* simultaneously and return the distinct instances observed.
* @param attr Attribute to query
* @param threads Number of concurrent callers
* @return Distinct {@link Phi} instances returned across threads
* @throws InterruptedException If interrupted while waiting
*/
private static Set<Phi> collectConcurrentGet(final Attr attr, final int threads)
throws InterruptedException {

Check warning on line 130 in eo-runtime/src/test/java/org/eolang/AtWithRhoTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the declaration of thrown exception 'java.lang.InterruptedException', as it cannot be thrown from method's body.

See more on https://sonarcloud.io/project/issues?id=objectionary_eo&issues=AZ26zLqlOVNS1JyjZOij&open=AZ26zLqlOVNS1JyjZOij&pullRequest=5023
Comment on lines +130 to +144
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.

⚠️ Potential issue | 🟡 Minor

Remove the unused throws InterruptedException.

Nothing in the method body throws InterruptedException: start.await() lives inside the worker lambda (and is caught there), start.countDown() never throws, and ExecutorService.close() does not declare it. SonarCloud flags this as a dead declaration.

🔧 Proposed fix
-    private static int distinctConcurrentGets(final Attr attr, final int threads)
-        throws InterruptedException {
+    private static int distinctConcurrentGets(final Attr attr, final int threads) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private static int distinctConcurrentGets(final Attr attr, final int threads)
throws InterruptedException {
private static int distinctConcurrentGets(final Attr attr, final int threads) {
🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis

[warning] 130-130: Remove the declaration of thrown exception 'java.lang.InterruptedException', as it cannot be thrown from method's body.

See more on https://sonarcloud.io/project/issues?id=objectionary_eo&issues=AZ26zLqlOVNS1JyjZOij&open=AZ26zLqlOVNS1JyjZOij&pullRequest=5023

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@eo-runtime/src/test/java/org/eolang/AtWithRhoTest.java` around lines 129 -
130, The method signature for distinctConcurrentGets declares a throws
InterruptedException even though nothing inside (including the worker lambda's
caught exceptions, start.countDown(), and ExecutorService.close()) throws it;
remove the "throws InterruptedException" from the distinctConcurrentGets method
declaration (the method named distinctConcurrentGets) and update any callers if
they relied on that checked exception (they should not need changes since no
InterruptedException is propagated).

final Set<Phi> results = ConcurrentHashMap.newKeySet();
final CountDownLatch start = new CountDownLatch(1);
try (ExecutorService pool = Executors.newFixedThreadPool(threads)) {
for (int idx = 0; idx < threads; ++idx) {
pool.submit(
() -> {
try {
start.await();
results.add(attr.get());
} catch (final InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
);
}
start.countDown();
}
return results;
}
}
Loading