Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@
private final Map<Integer, BufferedImage> cache;

/** Lock for thread-safe access to cache state */
private final ReentrantReadWriteLock lock;

/** Total frames loaded across all cache accesses */
private long totalLoads;
private final java.util.concurrent.atomic.LongAdder totalLoads;

Check notice on line 33 in src/main/java/org/opensourcephysics/cabrillo/tracker/video/FrameCache.java

View workflow job for this annotation

GitHub Actions / Kimi Code Review

[suggestion] Fully qualified class name used instead of import

Lines 30 and 33 use `java.util.concurrent.atomic.LongAdder` as a fully qualified name in field declarations, while `java.util.concurrent.locks.ReentrantReadWriteLock` (line 29) is imported normally. This inconsistency suggests AI-generated code that copy-pasted from documentation without cleaning up. Add a proper import and use the simple name.
Raw output
import java.util.concurrent.atomic.LongAdder;

    /** Total frames loaded across all cache accesses */
    private final LongAdder totalLoads;

    /** Total frame cache hits */
    private final LongAdder totalHits;

/** Total frame cache hits */
private long totalHits;
private final java.util.concurrent.atomic.LongAdder totalHits;
Comment on lines +33 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: LongAdder migration is partially redundant given existing lock coverage

The totalHits counter is incremented at FrameCache.java:89 inside get(), and totalLoads is incremented at FrameCache.java:119 inside put() under the write lock. If the bug in get() is fixed (restoring the write lock), both counters would always be modified under the write lock, making LongAdder unnecessary — plain long fields with volatile or lock-protected access would suffice. LongAdder adds overhead (memory for striped cells) without benefit when all mutations are already serialized by the write lock. This isn't a bug but is a design inconsistency worth noting.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


/**
* Creates a FrameCache with default maximum capacity of 100 frames.
Expand Down Expand Up @@ -63,8 +63,8 @@
}
};
this.lock = new ReentrantReadWriteLock();
this.totalLoads = 0;
this.totalHits = 0;
this.totalLoads = new java.util.concurrent.atomic.LongAdder();
this.totalHits = new java.util.concurrent.atomic.LongAdder();
}

/**
Expand All @@ -74,26 +74,26 @@
* This method does not load a new frame - call loadFrame() for that.</p>
*
* @param frameIndex the frame index to retrieve
* @return the cached Bufferedlmage, or null if not found or index is invalid
* @return the cached BufferedImage, or null if not found
* @throws IllegalStateException if frameIndex is invalid
*/
public BufferedImage get(int frameIndex) {
if (frameIndex < 0) {
throw new IllegalStateException("frameIndex cannot be negative: " + frameIndex);
}

lock.writeLock().lock();
lock.readLock().lock();
try {
BufferedImage frame = cache.get(frameIndex);
Comment on lines +85 to 87

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use write lock when reading from access-order cache

cache is a LinkedHashMap created with accessOrder=true, so calling get() mutates internal ordering. This change switched get(int frameIndex) to readLock, which allows multiple threads to call cache.get(...) concurrently and mutate the map without mutual exclusion, leading to race conditions and possible map corruption under concurrent cache hits. Keep get() under the write lock (or use a different concurrent LRU structure) so access-order updates remain serialized.

Useful? React with 👍 / 👎.

if (frame != null) {
totalHits++;
totalHits.increment();
}
return frame;
} finally {
lock.writeLock().unlock();
lock.readLock().unlock();
Comment on lines +85 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 ReadLock used for structurally-modifying LinkedHashMap.get() with accessOrder=true

The get() method was changed from writeLock to readLock, but the underlying LinkedHashMap is constructed with accessOrder=true (line 59). In an access-ordered LinkedHashMap, calling get() is a structural modification — it moves the accessed entry to the tail of the internal doubly-linked list to maintain LRU order. Multiple threads can now enter get() concurrently under the read lock, causing concurrent structural modifications to the LinkedHashMap's internal linked list. This is a data race that can corrupt the map's internal state, leading to infinite loops, lost entries, NullPointerExceptions, or other undefined behavior under concurrent access. The original code correctly used writeLock because this operation mutates the map.

Suggested change
lock.readLock().lock();
try {
BufferedImage frame = cache.get(frameIndex);
if (frame != null) {
totalHits++;
totalHits.increment();
}
return frame;
} finally {
lock.writeLock().unlock();
lock.readLock().unlock();
lock.writeLock().lock();
try {
BufferedImage frame = cache.get(frameIndex);
if (frame != null) {
totalHits.increment();
}
return frame;
} finally {
lock.writeLock().unlock();
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}
}

Check failure on line 96 in src/main/java/org/opensourcephysics/cabrillo/tracker/video/FrameCache.java

View workflow job for this annotation

GitHub Actions / Kimi Code Review

[critical] Read lock insufficient for access-ordered LinkedHashMap.get()

The change from `lock.writeLock()` to `lock.readLock()` in `get()` (line 85-86, 93-94) introduces a race condition. `LinkedHashMap` with `accessOrder=true` mutates its internal doubly-linked list on every `get()` to maintain LRU order. This structural modification is NOT safe under concurrent read access. The JavaDoc for `LinkedHashMap` states: 'If the map is structurally modified at any time after the iterator is created, the iterator will throw a ConcurrentModificationException'—and structural modifications include access-order reordering. Multiple threads calling `get()` simultaneously with read locks can corrupt the linked list pointers, leading to infinite loops, `NullPointerException`, or silent data corruption. The original write lock, while conservative, was correct. Either revert to write lock, or use a `StampedLock` with optimistic reads that validate and upgrade, or use `Collections.synchronizedMap()` wrapper.
Raw output
        lock.writeLock().lock();
        try {
            BufferedImage frame = cache.get(frameIndex);
            if (frame != null) {
                totalHits.increment();
            }
            return frame;
        } finally {
            lock.writeLock().unlock();
        }
/**
* Loads and stores a frame in the cache.
*
Expand All @@ -109,19 +109,18 @@
throw new IllegalArgumentException("frameIndex cannot be negative: " + frameIndex);
}
if (frame == null) {
throw new IllegalArgumentException("frame cannot be null");
}

totalLoads++;

lock.writeLock().lock();
try {
// Only update if not already present (hit means no load needed)
if (!cache.containsKey(frameIndex)) {
totalLoads.increment();
cache.put(frameIndex, frame);
}
Comment on lines 117 to 121

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚩 Semantic change: totalLoads now counts only actual inserts, not all put() calls

Previously, totalLoads++ was outside the lock and incremented on every put() call (FrameCache.java:115 old code), regardless of whether the frame was already cached. Now totalLoads.increment() at line 119 only fires when the key is absent (inside the !containsKey branch). This changes the meaning of totalLoads from "total put attempts" to "total actual cache insertions". This is arguably more correct for computing a meaningful hit ratio, but it's a behavioral change that could affect any monitoring or logging that relied on the old semantics. The getHitRatio() method (FrameCache.java:200-206) divides hits by loads — with the new semantics, loads only counts misses, so the ratio hits / loads no longer represents a traditional hit rate (hits / total requests). It now represents something like "hits per miss" which can exceed 1.0, contradicting the Javadoc at line 199 that says "between 0 and 1".

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

} finally {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [warning] Race condition in put(): totalLoads increment moved inside write lock but totalHits in get() remains under read lock—potential inconsistency in getHitRatio()

The PR moves totalLoads.increment() inside the write lock in put() (line 117), which serializes it with respect to other put() operations. However, totalHits.increment() in get() (line 88) remains under the read lock. This means totalHits and totalLoads can still skew relative to each other under concurrent access:

  • Thread A calls get(frameIndex) and finds a hit, increments totalHits under read lock
  • Thread B calls put(frameIndex, frame) concurrently, checks !cache.containsKey(frameIndex) under write lock, sees no entry
  • Thread A's get() happened before B's put(), but A's totalHits.increment() and B's totalLoads.increment() are not atomic with respect to each other
  • Result: totalHits reflects a hit that, from a strict cache-state perspective, might be considered stale if we wanted atomic metrics

However, this is acceptable for approximate statistics. The more subtle issue: getHitRatio() computes totalHits.sum() / totalLoads.sum() as two independent atomic reads. The ratio can temporarily exceed 1.0 or be negative in pathological interleavings (though with only increments, max is unbounded). With the old code, totalLoads++ was not atomic at all, so this is still an improvement.

This is not a critical bug but a design limitation: the metrics are best-effort, not transactionally consistent with cache state. The PR doesn't worsen this but doesn't fix the fundamental inconsistency either. Consider documenting that metrics are approximate.

lock.writeLock().unlock();

Check notice on line 123 in src/main/java/org/opensourcephysics/cabrillo/tracker/video/FrameCache.java

View workflow job for this annotation

GitHub Actions / Kimi Code Review

[suggestion] Redundant containsKey-then-put pattern could use computeIfAbsent

The `put()` method (line 117-121) checks `!cache.containsKey(frameIndex)` before `cache.put()`. This is an extra hash lookup. With the write lock held, `cache.put()` already returns the previous value; checking first is wasteful. However, since `totalLoads` must only increment on actual insertion (not replacement), the current pattern is required unless restructured. The code is correct but suboptimal. Consider documenting why the check is needed, or restructure to use the return value of `put()` to detect insertion.
}
}

Expand Down Expand Up @@ -178,45 +177,54 @@
/**
* Returns the total number of frame cache loads.
*
* @return total loads count
*/

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [warning] Unnecessary complexity: metric getters use LongAdder.sum() which requires no external synchronization

The PR description claims that getTotalLoads(), getTotalHits(), and getHitRatio() were "returning variables without taking a read lock" and that this is a race condition. This is incorrect. LongAdder.sum() is inherently thread-safe and provides memory visibility without external synchronization. The LongAdder class (Java 8+) uses striped counters with internal synchronization and volatile/cas operations. Adding read locks around these calls would be pure overhead with no correctness benefit.

The current code (after PR) correctly uses sum() without locks in getTotalLoads(), getTotalHits(), and getHitRatio(). This is the right approach. However, the PR description's reasoning is flawed and could mislead future maintainers into adding unnecessary locking. The code is correct as written in these methods; no change needed, but the commit message/rationale should be corrected if possible.

public long getTotalLoads() {
return totalLoads;
return totalLoads.sum();
}

/**
* Returns the total number of cache hits.
*
* @return total hits count
*/
public long getTotalHits() {
return totalHits;
return totalHits.sum();
}

/**
* Returns the cache hit ratio (hits / total loads).
*
* @return hit ratio as a double between 0 and 1, or 0.0 if no loads have occurred
*/
public double getHitRatio() {
if (totalLoads == 0) {
long loads = totalLoads.sum();
if (loads == 0) {
return 0.0;
}
return (double) totalHits / totalLoads;
return (double) totalHits.sum() / loads;
}

/**

Check warning on line 208 in src/main/java/org/opensourcephysics/cabrillo/tracker/video/FrameCache.java

View workflow job for this annotation

GitHub Actions / Kimi Code Review

[warning] Metric getters unnecessarily acquire no lock; LongAdder is already thread-safe

`getTotalLoads()`, `getTotalHits()`, and `getHitRatio()` (lines 180-208) were changed to call `.sum()` on `LongAdder` instances. The PR description claims these needed read locks for 'memory visibility,' but `LongAdder` extends `Striped64` and its `sum()` method is inherently thread-safe with happens-before guarantees. Adding read locks around these would be unnecessary; not adding them (as the code correctly does) contradicts the PR's own rationale. The metrics are correct as written, but the PR description is misleading about why. No code change needed, but the false justification suggests the author doesn't understand `java.util.concurrent.atomic` semantics.
* Returns a summary string showing cache statistics.
*
* @return statistics string
*/
@Override
public String toString() {
return String.format("FrameCache[size=%d/%d, hits=%d, loads=%d, ratio=%.2f%%]",
size(), maxSize, totalHits, totalLoads, getHitRatio() * 100);
lock.readLock().lock();
try {
long loads = totalLoads.sum();
long hits = totalHits.sum();
double ratio = loads == 0 ? 0.0 : (double) hits / loads;
return String.format("FrameCache[size=%d/%d, hits=%d, loads=%d, ratio=%.2f%%]",
cache.size(), maxSize, hits, loads, ratio * 100);
} finally {
lock.readLock().unlock();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 [critical] Deadlock risk: toString() acquires read lock while holding monitor on cache via LinkedHashMap.removeEldestEntry

The toString() method now acquires lock.readLock() and then calls cache.size() (line 220). Meanwhile, put() acquires lock.writeLock() and then calls cache.put(), which internally invokes the overridden removeEldestEntry() callback. This creates a classic lock-ordering hazard:

  • Thread A in toString(): holds readLock, calls cache.size() (needs LinkedHashMap's internal structure consistent, which is fine for read-only)
  • Thread B in put(): holds writeLock, calls cache.put() which may call removeEldestEntry()

More critically, if any code path ever calls toString() while already holding the write lock (e.g., debugging, logging inside a locked region), or if the JVM's LinkedHashMap implementation were to acquire any internal lock during size() or during the callback, this could deadlock. But the immediate, concrete issue is simpler: toString() now takes a read lock unnecessarily. LongAdder.sum() provides its own memory visibility—no external lock is required. The cache.size() call also does not need the external lock for atomicity; ConcurrentHashMap would, but LinkedHashMap is not thread-safe anyway, so the read lock here is a band-aid that doesn't actually make size() safe against concurrent modifications from put()'s write lock (the write lock does protect it, but then size() under read lock is safe—however, the combination of cache.size() + totalLoads.sum() + totalHits.sum() is not atomic regardless, so the lock doesn't achieve consistency).

The real bug: if toString() is called from within a context that already holds the write lock (reentrant read locks don't allow upgrading, but reentrant write locks do allow downgrading—however, if a thread holds writeLock and calls toString(), it will try to acquire readLock while holding writeLock; ReentrantReadWriteLock supports lock downgrading, but this requires acquiring readLock before releasing writeLock, not after. A thread cannot acquire readLock while holding writeLock in the standard implementation? Actually, it can—ReentrantReadWriteLock allows a thread that holds the write lock to also acquire the read lock. But the reverse is not true. So this specific deadlock may not occur with a single thread.

However, the more serious issue: the PR adds complexity and lock contention for no benefit. LongAdder is specifically designed to avoid locking. Wrapping sum() calls in read locks defeats its purpose and adds overhead. The toString() method should simply call sum() without any lock, as the individual values are individually consistent and String.format will produce a coherent snapshot.

Suggested fix: Remove the read lock from toString() entirely. The LongAdder fields provide sufficient visibility and atomicity for their individual operations.

Suggested fix:

Suggested change
lock.readLock().unlock();
@Override
public String toString() {
long loads = totalLoads.sum();
long hits = totalHits.sum();
double ratio = loads == 0 ? 0.0 : (double) hits / loads;
return String.format("FrameCache[size=%d/%d, hits=%d, loads=%d, ratio=%.2f%%]",
cache.size(), maxSize, hits, loads, ratio * 100);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 [suggestion] toString() under read lock serializes all logging/monitoring callers unnecessarily

Even if the deadlock concern were addressed, taking a read lock in toString() harms scalability. toString() may be called frequently for logging, debugging, or monitoring. With LongAdder fields, the uncontended fast path is extremely cheap. Adding a ReentrantReadWriteLock acquisition—even read lock—introduces cache-line bouncing on the lock's internal state and may cause contention under high read volume.

The LongAdder design specifically optimizes for high-contention increment scenarios by reducing write contention. Wrapping its reads in a lock partially undermines this optimization for the read side.

Remove the lock and rely on LongAdder.sum() and cache.size() (accepting that size() on LinkedHashMap is a simple field read, not atomic with the adders, which is fine for diagnostic output).

Suggested fix:

Suggested change
lock.readLock().unlock();
@Override
public String toString() {
long loads = totalLoads.sum();
long hits = totalHits.sum();
double ratio = loads == 0 ? 0.0 : (double) hits / loads;
return String.format("FrameCache[size=%d/%d, hits=%d, loads=%d, ratio=%.2f%%]",
cache.size(), maxSize, hits, loads, ratio * 100);
}

}
}

/**

Check warning on line 227 in src/main/java/org/opensourcephysics/cabrillo/tracker/video/FrameCache.java

View workflow job for this annotation

GitHub Actions / Kimi Code Review

[warning] toString() read lock unnecessary and creates lock-ordering hazard

The `toString()` method now acquires `readLock()` (line 213, 226) to read `cache.size()`, `totalLoads.sum()`, and `totalHits.sum()`. However, `LongAdder` already provides atomicity and memory visibility without locking—`sum()` is thread-safe by design. The `cache.size()` call could use the lock, but `toString()` is typically for debugging and should not risk deadlock. More critically, this creates a lock-ordering inconsistency: `toString()` locks then calls `cache.size()`, but `size()` (line 200-208) also acquires the same read lock. While reentrant read locks allow nested acquisition, this pattern is fragile and suggests the author didn't understand `LongAdder`'s thread-safety guarantees. The lock adds latency to a diagnostic method with no correctness benefit.
Raw output
    @Override
    public String toString() {
        long loads = totalLoads.sum();
        long hits = totalHits.sum();
        double ratio = loads == 0 ? 0.0 : (double) hits / loads;
        return String.format("FrameCache[size=%d/%d, hits=%d, loads=%d, ratio=%.2f%%]",
            size(), maxSize, hits, loads, ratio * 100);
    }
* Simple test of the FrameCache implementation.
*
* @param args command line arguments (unused)
Expand Down
Loading