Repository navigation
fix(kotlin-mutex): ask the mutex who holds it, so an unseen unlock leaves no stale holder (#1221) - #1223
Merged
Conversation
kotlinx.coroutines.sync.Mutex is not reentrant. A coroutine that calls lock() or withLock() on a mutex it already owns suspends forever: no exception, no stack trace, just a test that hangs until its timeout. Nothing in the library saw it, because the Mutex is a library object whose calls never pass through code the agent wove. KotlinMutexWeaver replaces the Mutex lock, tryLock, unlock and withLock call sites in woven classes with static hooks in KotlinMutexes, which record the holder and report KotlinMutexReentrancyDetector's finding when the same holder asks again. Each hook calls ControlledSchedule.point() first, like runBlockingEntered, because every call the agent replaces must also be a point where a controlled schedule can switch threads (#1023). Still open, and documented in docs/JVM_LANGUAGES.md: a holder that had to wait or that took the mutex with tryLock is never judged, and races across suspensions stay with #1007. Verified: library fast tier 3,931 tests and agent 117 tests green; the Kotlin fixture against real kotlinx and the consumer fixture (287 tests, new Phase26KotlinMutexFixtureTest) green; example 152 passes under Maven and Gradle and shows the real hang under a timeout plus the fixed twin passing. Static gate chain (install -DskipTests) and tools/guardrail-drift.sh re-run on this exact tree, both green. Class diagram regenerated: the only new nodes are KotlinMutexes and KotlinMutexReentrancyDetector. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
) agent_kotlinMutex_lockedAgainByItsHolder locks a fresh kotlinx Mutex twice from one coroutine; agent_kotlinMutex_unlockedBetweenLocks unlocks in between. Both call the real kotlinx 1.11 Mutex from the test body, so they sit in BODY_CALL_SITE_ROWS and LibraryReach records why no corpus library reaches the detector. The detector is held at PROMPT on its model (PairEvidence.HELD_ON_MODEL and a "# held:" line in verdict-evidence-corpus): an unlock outside includes= leaves a stale holder that can draw a false reentry (#1221). Doc counts move with the corpus: 149 of 149 paired, 51 in the agent-pair lane, ten corpus libraries.
…ntrancy class-diagram.svg conflicted (generated): regenerated with tools/generate-architecture-diagrams.sh, which keeps main's AgentHandOffHooks (#1218) and adds this branch's KotlinMutexReentrancyDetector and KotlinMutexes.
…aves no stale holder (#1221) The detector recorded a holder at a woven lock that took the mutex without waiting and cleared it at a woven unlock. An unlock in a class outside includes= was never seen, so the holder stayed recorded, and a later lock from that coroutine that waited for another holder was reported as a reentry. A lock that had to wait never became a holder, so a reentry under it was missed (#1220). A woven lock called without an owner is now made with a fresh owner token of the agent's. kotlinx compares owners by identity, so the token matches nothing else: the lock, unlock(null) and tryLock behave as with no owner (checked on kotlinx 1.6.4, 1.8.0 and 1.11.0; the token prints as "null"). The detector keeps each token with the caller's Job, and before a later woven lock asks Mutex.holdsLock which of them holds the mutex now. The mutex answers, so an unseen unlock ends a hold like any other, and a lock that waited holds its token once granted. Unlock and tryLock no longer record anything. The hand-recording API (recordAcquired, recordHolderUnknown, holderAmong) is unchanged. Tests first, against real kotlinx 1.11 in consumer-fixture-langs/kotlin: lockAgainAfterAnUnseenHandOver (unlock and relock in an unwoven package, then a woven lock that waits for the other holder) was reported before the fix and is silent after; withLockAfterWaitingFor- TheMutex (a supervisorScope reentry under a lock that waited) was missed before and is reported after. With the hold's reason gone, KOTLIN_MUTEX_REENTRANCY is promoted to VERDICT on its agent-pair rows; the catalog split moves to 38 VERDICT, 75 PROMPT. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1221) CorpusClaimsInDocsTest: the README and the corpus workflow header state the count of detectors whose VERDICT rests on a corpus pair, and the promotion moves it from 34 to 35. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…feed gate reads (#1221) Moving the lock bookkeeping into KotlinMutexes.LockWatch took the AsyncTestContext.currentKotlinMutexReentrancyDetector() call out of AgentConcurrencyUtilHooks, so DetectorFeedCoverageTest, which reads only Agent*Hooks sources as proof a woven stream delivers to a detector, no longer found KOTLIN_MUTEX_REENTRANCY's feed. The hooks now look the detector up and pass it to LockWatch.before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #1222: review the last two commits only. Merge #1222 first, with a merge commit.
Fixes #1221. Partly addresses #1220: a reentry under a lock that had to wait is now reported. A holder that took the mutex through
tryLockorselectis still not known.Why
KotlinMutexReentrancyDetectorrecorded a holder when a wovenlocktook the mutex without waiting, and cleared it on a wovenunlock. Two cases went wrong:unlockin a class outsideincludes=was never seen, so the holder stayed recorded. If that coroutine later locked the mutex and had to wait for another holder, the detector reported a reentry that never happened.The hold in
verdict-evidence-corpuskept the detector at PROMPT for the first reason, "until the detector checks the recorded holder against the mutex's own state". This PR adds that check.What
lockcalled without an owner is now made with a fresh token of the agent's. kotlinx compares owners by identity, so the token matches nothing else. I checked in jshell on kotlinx 1.6.4, 1.8.0 and 1.11.0 thatlock,unlock(null),tryLockand a lock that waits and is granted later all behave as they do with no owner. The token'stoStringis"null", so the mutex prints the same as before on 1.8 and later.Job. Before a later wovenlock, it asksMutex.holdsLock(token)which of them holds the mutex right now. Anunlockthe agent never saw therefore ends a hold like any other, and a lock that waited holds its token once it is granted.tryLockno longer record anything.recordAcquired,recordHolderUnknown,holderAmong(mutex, waiting)) is unchanged, so the Phase 26 fixture, example 152 and the catalog's snippet still work.KOTLIN_MUTEX_REENTRANCYis promoted to VERDICT. Its corpus pair (agent_kotlinMutex_lockedAgainByItsHolder/agent_kotlinMutex_unlockedBetweenLocks) is registered inverdict-evidence-corpus, and theHELD_ON_MODELentry is removed. The catalog split moves to 38 VERDICT and 75 PROMPT. The corpus-backed count in the README and the corpus workflow header moves from 34 to 35.How it was verified
consumer-fixture-langs/kotlin, with the library from Detect a coroutine re-entering a kotlinx Mutex it already holds (#1007) #1222 installed:lockAgainAfterAnUnseenHandOver: the mutex is unlocked and then locked by another coroutine, both incom.example.unwoven, outsideincludes=. Then a woven lock waits for that other holder. Reported before the fix, silent after.withLockAfterWaitingForTheMutex: asupervisorScopereentry under a lock that had to wait. Missed before the fix, reported after.KotlinMutexReentrancyDetectorTest: 18 tests, 7 of them new (a token holds while the mutex says so, a token released unseen is dropped, a waited lock holds once granted, the bounds, hand-recording and tokens kept apart).DetectorTrustCoverageTest,DetectorCatalogCoverageTest,DocsIndexCoverageTest,StructuredViolationCoverageTest,DetectorAnalysisUnderConcurrentWritesTest,AgentConcurrencyUtilHooksTest: green.Checklist
VIBETAGS-START/VIBETAGS-ENDmarkers (the regenerated guardrail files carry the new@AIThreadSafenote)recordLockedandholderAmong(mutex, waiting, holds)are new,@since 1.14.2. The detector has not been released yet.Co-Authored-BytrailerAnything reviewers should push back on
lock,unlock(null),tryLockandholdsLock. From kotlinx 1.8 on, nothing visible changes: the mutex'stoString, and the message of anunlock(someOwner)after an owner-lesslock, both print the owner, and the token prints asnull, exactly as before. On kotlinx before 1.8, both printnullwhere they printedLOCKED. The exception type is the same.DetectorTrustrow and the evidence line, and restore the hold with a new reason.lock-overrideifDetectorTrustor the evidence file is locked.🤖 Generated with Claude Code