2929import java .util .Set ;
3030import java .util .concurrent .ConcurrentHashMap ;
3131import java .util .concurrent .ConcurrentMap ;
32- import java .util .concurrent .locks .ReadWriteLock ;
33- import java .util .concurrent .locks .ReentrantReadWriteLock ;
32+ import java .util .concurrent .Executors ;
33+ import java .util .concurrent .ScheduledExecutorService ;
34+ import java .util .concurrent .TimeUnit ;
3435import java .util .logging .Logger ;
3536import org .openqa .selenium .NoSuchSessionException ;
3637import org .openqa .selenium .events .Event ;
5051import org .openqa .selenium .remote .tracing .Span ;
5152import org .openqa .selenium .remote .tracing .Tracer ;
5253
53- public class LocalSessionMap extends SessionMap {
54+ public class LocalSessionMap extends SessionMap implements AutoCloseable {
5455
5556 private static final Logger LOG = Logger .getLogger (LocalSessionMap .class .getName ());
5657
5758 private final EventBus bus ;
5859 private final IndexedSessionMap knownSessions = new IndexedSessionMap ();
60+ private final ScheduledExecutorService maintenanceExecutor ;
5961
6062 public LocalSessionMap (Tracer tracer , EventBus bus ) {
6163 super (tracer );
6264
6365 this .bus = Require .nonNull ("Event bus" , bus );
6466
67+ // Initialize maintenance executor for periodic cleanup
68+ this .maintenanceExecutor =
69+ Executors .newSingleThreadScheduledExecutor (
70+ r -> {
71+ Thread t = new Thread (r , "LocalSessionMap-Maintenance" );
72+ t .setDaemon (true );
73+ return t ;
74+ });
75+
76+ // Schedule maintenance cleanup every 5 minutes to prevent memory leaks
77+ this .maintenanceExecutor .scheduleWithFixedDelay (
78+ this ::performMaintenanceCleanup ,
79+ 5 , // initial delay
80+ 5 , // period
81+ TimeUnit .MINUTES );
82+
6583 bus .addListener (SessionClosedEvent .listener (this ::remove ));
6684
6785 bus .addListener (
@@ -92,17 +110,21 @@ public boolean isReady() {
92110 @ Override
93111 public boolean add (Session session ) {
94112 Require .nonNull ("Session" , session );
95- SessionId id = session .getId ();
96113
97- // IndexedSessionMap now handles internal synchronization
114+ SessionId id = session . getId ();
98115 knownSessions .put (id , session );
99116
100117 try (Span span = tracer .getCurrentContext ().createSpan ("local_sessionmap.add" )) {
101118 AttributeMap attributeMap = tracer .createAttributeMap ();
102119 attributeMap .put (AttributeKey .LOGGER_CLASS .getKey (), getClass ().getName ());
103120 SESSION_ID .accept (span , id );
104121 SESSION_ID_EVENT .accept (attributeMap , id );
105- span .addEvent ("Added session into local Session Map" , attributeMap );
122+
123+ String sessionAddedMessage =
124+ String .format (
125+ "Added session to local Session Map, Id: %s, Node: %s" , id , session .getUri ());
126+ span .addEvent (sessionAddedMessage , attributeMap );
127+ LOG .info (sessionAddedMessage );
106128 }
107129
108130 return true ;
@@ -132,6 +154,7 @@ public void remove(SessionId id) {
132154 attributeMap .put (AttributeKey .LOGGER_CLASS .getKey (), getClass ().getName ());
133155 SESSION_ID .accept (span , id );
134156 SESSION_ID_EVENT .accept (attributeMap , id );
157+
135158 String sessionDeletedMessage =
136159 String .format (
137160 "Deleted session from local Session Map, Id: %s, Node: %s" ,
@@ -157,199 +180,227 @@ private void batchRemoveByUri(URI externalUri, Class<? extends Event> eventClass
157180 attributeMap .put ("node.uri" , externalUri .toString ());
158181 attributeMap .put ("sessions.count" , sessionsToRemove .size ());
159182
160- LOG .info (
161- String .format (
162- "Event %s triggered batch remove from local Session Map for Node %s" ,
163- eventClass .getName (), externalUri ));
164- String eventMessage = "" ;
165183 if (!sessionsToRemove .isEmpty ()) {
166- eventMessage =
184+ String batchRemoveMessage =
167185 String .format (
168- "Batch removed %d sessions belonging to Node %s including: %s" ,
169- sessionsToRemove .size (), externalUri , sessionsToRemove );
186+ "Batch removed %d sessions from local Session Map for Node %s (triggered by %s):"
187+ + " %s" ,
188+ sessionsToRemove .size (), externalUri , eventClass .getSimpleName (), sessionsToRemove );
189+ span .addEvent (batchRemoveMessage , attributeMap );
190+ LOG .info (batchRemoveMessage );
170191 } else {
171- eventMessage =
192+ String noSessionsMessage =
172193 String .format (
173- "No sessions found to remove from local Session Map for Node %s" , externalUri );
194+ "No sessions found to remove from local Session Map for Node %s (triggered by %s)" ,
195+ externalUri , eventClass .getSimpleName ());
196+ span .addEvent (noSessionsMessage , attributeMap );
197+ }
198+ }
199+ }
200+
201+ private void performMaintenanceCleanup () {
202+ knownSessions .performMaintenanceCleanup ();
203+ }
204+
205+ /**
206+ * Shutdown the maintenance executor and perform final cleanup. This method should be called when
207+ * the LocalSessionMap is no longer needed.
208+ */
209+ @ Override
210+ public void close () {
211+ try {
212+ // Perform final maintenance cleanup before shutdown
213+ performMaintenanceCleanup ();
214+
215+ // Shutdown the maintenance executor gracefully
216+ maintenanceExecutor .shutdown ();
217+ if (!maintenanceExecutor .awaitTermination (10 , TimeUnit .SECONDS )) {
218+ LOG .warning ("Maintenance executor did not terminate gracefully, forcing shutdown" );
219+ maintenanceExecutor .shutdownNow ();
174220 }
175- span .addEvent (eventMessage , attributeMap );
176- LOG .info (eventMessage );
221+
222+ LOG .info ("LocalSessionMap maintenance executor shutdown completed" );
223+ } catch (InterruptedException e ) {
224+ Thread .currentThread ().interrupt ();
225+ maintenanceExecutor .shutdownNow ();
226+ LOG .warning ("Interrupted during maintenance executor shutdown" );
227+ } catch (Exception e ) {
228+ LOG .warning ("Error during LocalSessionMap shutdown: " + e .getMessage ());
177229 }
178230 }
179231
180232 /** Custom ConcurrentMap implementation that automatically maintains a URI-to-SessionId index */
181233 private static class IndexedSessionMap {
182234 private final ConcurrentMap <SessionId , Session > sessions = new ConcurrentHashMap <>();
183235 private final ConcurrentMap <URI , Set <SessionId >> sessionsByUri = new ConcurrentHashMap <>();
184- // Internal lock to ensure atomicity of multi-step operations across both maps
185- private final ReadWriteLock internalLock = new ReentrantReadWriteLock ();
236+ // Simplified lock strategy: only for operations requiring coordination between both maps
237+ private final Object coordinationLock = new Object ();
186238
187239 public Session get (SessionId id ) {
188- // Read operations are atomic on ConcurrentHashMap - no lock needed
240+ // ConcurrentHashMap is already thread-safe for reads - no lock needed
189241 return sessions .get (id );
190242 }
191243
192244 public Session put (SessionId id , Session session ) {
193- // Write lock needed: multiple operations across both maps must be atomic
194- internalLock .writeLock ().lock ();
195- try {
245+ // Synchronize only the coordination between maps, not individual map operations
246+ synchronized (coordinationLock ) {
196247 Session previous = sessions .put (id , session );
197248
249+ // Clean up previous session's URI index if it exists
198250 if (previous != null && previous .getUri () != null ) {
199251 cleanupUriIndex (previous .getUri (), id );
200252 }
201253
254+ // Add new session to URI index
202255 URI sessionUri = session .getUri ();
203256 if (sessionUri != null ) {
204257 sessionsByUri .computeIfAbsent (sessionUri , k -> ConcurrentHashMap .newKeySet ()).add (id );
205258 }
206259
207260 return previous ;
208- } finally {
209- internalLock .writeLock ().unlock ();
210261 }
211262 }
212263
213264 public Session remove (SessionId id ) {
214- // Write lock needed: multiple operations across both maps must be atomic
215- internalLock .writeLock ().lock ();
216- try {
265+ // Synchronize only the coordination between maps
266+ synchronized (coordinationLock ) {
217267 Session removed = sessions .remove (id );
218268
269+ // Clean up URI index if session existed
219270 if (removed != null && removed .getUri () != null ) {
220271 cleanupUriIndex (removed .getUri (), id );
221272 }
222273
223274 return removed ;
224- } finally {
225- internalLock .writeLock ().unlock ();
226275 }
227276 }
228277
229278 public void batchRemove (Set <SessionId > sessionIds ) {
230- // Write lock needed: multiple operations across both maps must be atomic
231- internalLock .writeLock ().lock ();
232- try {
279+ // Synchronize the entire batch operation for consistency
280+ synchronized (coordinationLock ) {
233281 Map <URI , Set <SessionId >> uriToSessionIds = new HashMap <>();
234282
283+ // Collect URI mappings for sessions that exist
235284 for (SessionId id : sessionIds ) {
236285 Session session = sessions .get (id );
237286 if (session != null && session .getUri () != null ) {
238287 uriToSessionIds .computeIfAbsent (session .getUri (), k -> new HashSet <>()).add (id );
239288 }
240289 }
241290
291+ // Remove all sessions from main map
242292 for (SessionId id : sessionIds ) {
243293 sessions .remove (id );
244294 }
245295
246- // Robust cleanup for each URI
296+ // Clean up URI index for all affected URIs
247297 for (Map .Entry <URI , Set <SessionId >> entry : uriToSessionIds .entrySet ()) {
248298 URI uri = entry .getKey ();
249299 Set <SessionId > idsToRemove = entry .getValue ();
250300 cleanupUriIndex (uri , idsToRemove );
251301 }
252- } finally {
253- internalLock .writeLock ().unlock ();
254302 }
255303 }
256304
257305 /**
258306 * Robust cleanup of URI index to prevent memory leaks from empty sets. Handles single session
259- * removal with explicit empty set cleanup.
307+ * removal with explicit empty set cleanup. Uses atomic operations to prevent race conditions.
308+ * Note: This method should only be called from within coordinationLock synchronized blocks.
260309 */
261310 private void cleanupUriIndex (URI uri , SessionId sessionId ) {
262- Set < SessionId > sessionIds = sessionsByUri . get ( uri );
263- if ( sessionIds != null ) {
264- sessionIds . remove ( sessionId );
265- // Explicit check and removal to prevent memory leaks
266- if ( sessionIds .isEmpty ()) {
267- sessionsByUri . remove ( uri , sessionIds );
268- }
269- }
311+ // Use computeIfPresent for atomic operation to prevent race conditions
312+ sessionsByUri . computeIfPresent (
313+ uri ,
314+ ( key , sessionIds ) -> {
315+ sessionIds .remove ( sessionId );
316+ // Return null to remove the entry if empty, otherwise return the modified set
317+ return sessionIds . isEmpty () ? null : sessionIds ;
318+ });
270319 }
271320
272321 /**
273322 * Robust cleanup of URI index to prevent memory leaks from empty sets. Handles batch session
274- * removal with explicit empty set cleanup.
323+ * removal with explicit empty set cleanup. Uses atomic operations to prevent race conditions.
324+ * Note: This method should only be called from within coordinationLock synchronized blocks.
275325 */
276326 private void cleanupUriIndex (URI uri , Set <SessionId > sessionIdsToRemove ) {
277- Set < SessionId > sessionIds = sessionsByUri . get ( uri );
278- if ( sessionIds != null ) {
279- sessionIds . removeAll ( sessionIdsToRemove );
280- // Explicit check and removal to prevent memory leaks
281- if ( sessionIds .isEmpty ()) {
282- sessionsByUri . remove ( uri , sessionIds );
283- }
284- }
327+ // Use computeIfPresent for atomic operation to prevent race conditions
328+ sessionsByUri . computeIfPresent (
329+ uri ,
330+ ( key , sessionIds ) -> {
331+ sessionIds .removeAll ( sessionIdsToRemove );
332+ // Return null to remove the entry if empty, otherwise return the modified set
333+ return sessionIds . isEmpty () ? null : sessionIds ;
334+ });
285335 }
286336
287337 /**
288338 * Periodic cleanup to remove any empty sets that may have been missed. Should be called
289- * periodically to prevent memory leaks.
339+ * periodically to prevent memory leaks. Enhanced with better error handling and logging.
290340 */
291341 public void performMaintenanceCleanup () {
292- internalLock .writeLock ().lock ();
293- try {
294- // Find and remove empty URI sets
295- Set <URI > emptyUris = new HashSet <>();
296- for (Map .Entry <URI , Set <SessionId >> entry : sessionsByUri .entrySet ()) {
297- if (entry .getValue ().isEmpty ()) {
298- emptyUris .add (entry .getKey ());
342+ synchronized (coordinationLock ) {
343+ try {
344+ int initialSize = sessionsByUri .size ();
345+ int emptyUrisRemoved = 0 ;
346+
347+ // Use iterator to safely remove empty entries during iteration
348+ var iterator = sessionsByUri .entrySet ().iterator ();
349+ while (iterator .hasNext ()) {
350+ var entry = iterator .next ();
351+ if (entry .getValue ().isEmpty ()) {
352+ iterator .remove ();
353+ emptyUrisRemoved ++;
354+ }
299355 }
300- }
301356
302- for (URI emptyUri : emptyUris ) {
303- sessionsByUri .remove (emptyUri );
357+ if (emptyUrisRemoved > 0 ) {
358+ LOG .info (
359+ String .format (
360+ "Maintenance cleanup removed %d empty URI entries from sessionsByUri map (size:"
361+ + " %d -> %d)" ,
362+ emptyUrisRemoved , initialSize , sessionsByUri .size ()));
363+ }
364+ } catch (Exception e ) {
365+ LOG .warning ("Error during maintenance cleanup: " + e .getMessage ());
304366 }
305- } finally {
306- internalLock .writeLock ().unlock ();
307367 }
308368 }
309369
310370 public Set <SessionId > getSessionsByUri (URI uri ) {
311- // Read operations are atomic on ConcurrentHashMap - no lock needed
371+ // ConcurrentHashMap is already thread-safe for reads - no lock needed
312372 Set <SessionId > result = sessionsByUri .get (uri );
313373 // Return empty set instead of null, and ensure we don't return empty sets
314374 return (result != null && !result .isEmpty ()) ? result : Set .of ();
315375 }
316376
317377 public Set <Map .Entry <SessionId , Session >> entrySet () {
318- // Read lock to ensure consistent view during iteration
319- internalLock .readLock ().lock ();
320- try {
321- return new HashSet <>(sessions .entrySet ());
322- } finally {
323- internalLock .readLock ().unlock ();
324- }
378+ // ConcurrentHashMap provides thread-safe iteration - no lock needed
379+ // Create defensive copy to prevent external modification
380+ return new HashSet <>(sessions .entrySet ());
325381 }
326382
327383 public Collection <Session > values () {
328- // Read lock to ensure consistent view during iteration
329- internalLock .readLock ().lock ();
330- try {
331- return new ArrayList <>(sessions .values ());
332- } finally {
333- internalLock .readLock ().unlock ();
334- }
384+ // ConcurrentHashMap provides thread-safe iteration - no lock needed
385+ // Create defensive copy to prevent external modification
386+ return new ArrayList <>(sessions .values ());
335387 }
336388
337389 public int size () {
390+ // ConcurrentHashMap size() is already thread-safe
338391 return sessions .size ();
339392 }
340393
341394 public boolean isEmpty () {
395+ // ConcurrentHashMap isEmpty() is already thread-safe
342396 return sessions .isEmpty ();
343397 }
344398
345399 public void clear () {
346- // Write lock needed: multiple operations across both maps must be atomic
347- internalLock .writeLock ().lock ();
348- try {
400+ // Synchronize clearing both maps for consistency
401+ synchronized (coordinationLock ) {
349402 sessions .clear ();
350403 sessionsByUri .clear ();
351- } finally {
352- internalLock .writeLock ().unlock ();
353404 }
354405 }
355406 }
0 commit comments