Skip to content

Fix ConcurrentModificationException in transcript handling (#102) - #103

Open
xiajon wants to merge 1 commit into
amazon-connect:mainfrom
xiajon:fix/issue-102-transcript-concurrency
Open

Fix ConcurrentModificationException in transcript handling (#102)#103
xiajon wants to merge 1 commit into
amazon-connect:mainfrom
xiajon:fix/issue-102-transcript-concurrency

Conversation

@xiajon

@xiajon xiajon commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Issue

Fixes #102ConcurrentModificationException in ChatServiceImpl.handleTranscriptItemUpdate.

Root cause

transcriptDict (a LinkedHashMap) and internalTranscript (an ArrayList) are read and mutated from three threads with no synchronization:

  • Main dispatcher — the service's own coroutines (handleTranscriptItemUpdate iterates internalTranscript via indexOfFirst).
  • Timer threadremoveTypingIndicators calls removeIf on both collections when the typing indicator expires.
  • Dispatchers.IO — every ChatSession suspend method (sendMessage, getTranscript, resendFailedMessage, …) runs its ChatService call on IO.

A structural modification on one thread invalidates an in-flight iteration on another → ConcurrentModificationException.

Why the fix touches more than the reported function

The stack trace in #102 points at handleTranscriptItemUpdate, but that is only where the crash surfaced. I verified empirically that guarding that one function just relocates the crash: with only handleTranscriptItemUpdate synchronized, the regression test then throws at getRecentDisplayName; guarding that too, it throws in removeTypingIndicators. A lock only works if every accessor takes it.

The fix

  • A single reentrant monitor (transcriptLock) guards all access to the shared collections.
  • Snapshots are taken under the lock and handed to subscribers; the lock is never held across a coroutine suspension point.
  • Compound read-modify-write sequences (e.g. indexOfFirst-then-insert) run inside one locked region.

A concurrent collection (ConcurrentHashMap / CopyOnWriteArrayList) is not sufficient here: it makes each individual operation thread-safe but does not make the compound index-then-mutate atomic. I confirmed this — swapping in concurrent collections makes the CME disappear but surfaces an ArrayIndexOutOfBoundsException and duplicate items instead.

The change is deliberately mechanical: existing control flow is preserved and each hunk wraps existing statements in synchronized(transcriptLock) { … }. No message-handling logic was altered.

Testing

  • New regression test test_concurrentTranscriptUpdates_doNotThrowConcurrentModificationException drives the transcript from 8 threads; it fails with ConcurrentModificationException on main and passes with this change.
  • Full suite: 188/188 passing (./gradlew :chat-sdk:testDebugUnitTest --rerun-tasks).

Follow-ups (not in this PR)

  • The lock guards the containers, not the mutable Message/MessageMetadata items handed to subscribers; per-item field mutation while the UI reads the same instance is a separate concern.
  • Encapsulating the collections behind a small TranscriptStore type would make "hold the lock" structurally enforced rather than a convention.

…nnect#102)

handleTranscriptItemUpdate iterates internalTranscript (indexOfFirst)
on the service's Main-dispatcher coroutine while other threads mutate
the same collection: the typing-indicator Timer thread (removeIf in
removeTypingIndicators) and the Dispatchers.IO coroutines that back
ChatSession's suspend methods. A structural modification on one thread
invalidates an in-flight iteration on another, throwing
ConcurrentModificationException (issue amazon-connect#102).

The crash reported in amazon-connect#102 points at handleTranscriptItemUpdate, but
that is only where it surfaces. transcriptDict and internalTranscript
are read and written from all three threads across many methods;
guarding only the reported site just relocates the crash to the next
unsynchronized reader (verified: getRecentDisplayName and
removeTypingIndicators both throw once handleTranscriptItemUpdate alone
is guarded).

Guard every access to transcriptDict and internalTranscript with a
single reentrant monitor (transcriptLock). Snapshots are taken under
the lock and handed to subscribers, and the lock is never held across a
coroutine suspension point. Compound read-modify-write sequences (the
indexOfFirst-then-insert in handleTranscriptItemUpdate) run inside one
locked region, which a concurrent collection such as ConcurrentHashMap
or CopyOnWriteArrayList would not fix.

Adds a regression test that drives the transcript from 8 threads and
fails with ConcurrentModificationException without this change.
@xiajon
xiajon requested a review from a team as a code owner August 6, 2026 21:03
@xiajon
xiajon requested review from agarwhi and spenlep-amzn August 6, 2026 21:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ConcurrentModificationException in ChatServiceImpl.handleTranscriptItemUpdate when iterating transcript list

1 participant