Skip to content

fix(kotlin-mutex): ask the mutex who holds it, so an unseen unlock leaves no stale holder (#1221) - #1223

Merged
PIsberg merged 7 commits into
mainfrom
fix/1221-mutex-holder-check
Nov 8, 2026
Merged

PIsberg merged 7 commits into
mainfrom
fix/1221-mutex-holder-check

Conversation

@PIsberg

@PIsberg PIsberg commented Nov 8, 2026

Copy link
Copy Markdown
Owner

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 tryLock or select is still not known.

Why

KotlinMutexReentrancyDetector recorded a holder when a woven lock took the mutex without waiting, and cleared it on a woven unlock. Two cases went wrong:

The hold in verdict-evidence-corpus kept 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

  • Owner token. A woven lock called 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 that lock, unlock(null), tryLock and a lock that waits and is granted later all behave as they do with no owner. The token's toString is "null", so the mutex prints the same as before on 1.8 and later.
  • Asking the mutex. The detector keeps each token with the caller's Job. Before a later woven lock, it asks Mutex.holdsLock(token) which of them holds the mutex right now. An unlock the agent never saw therefore ends a hold like any other, and a lock that waited holds its token once it is granted.
  • Unlock and tryLock no longer record anything.
  • Bounds. A token is dropped once it has held the mutex and let it go. Tokens still waiting are capped at 64 per mutex, oldest dropped first; dropping one can only miss a finding.
  • Recording by hand (recordAcquired, recordHolderUnknown, holderAmong(mutex, waiting)) is unchanged, so the Phase 26 fixture, example 152 and the catalog's snippet still work.
  • Trust tier. KOTLIN_MUTEX_REENTRANCY is promoted to VERDICT. Its corpus pair (agent_kotlinMutex_lockedAgainByItsHolder / agent_kotlinMutex_unlockedBetweenLocks) is registered in verdict-evidence-corpus, and the HELD_ON_MODEL entry 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

  • Tests first, against real kotlinx 1.11 in 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 in com.example.unwoven, outside includes=. Then a woven lock waits for that other holder. Reported before the fix, silent after.
    • withLockAfterWaitingForTheMutex: a supervisorScope reentry under a lock that had to wait. Missed before the fix, reported after.
    • The seven existing fixtures still pass.
  • 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.
  • Corpus eval against a fresh isolated install: all six lanes green, 0 false positives. Both Mutex rows ran in the agent-pair lane, and the pair gates accept the new evidence line.

Checklist

  • A behaviour change ships with a test that fails without the fix
  • A new or changed detector is tested against the buggy code and its correctly synchronized twin, and its reported severity matches what it can actually prove
  • Docs the change makes wrong are updated in this same change
  • Nothing was hand-edited between VIBETAGS-START / VIBETAGS-END markers (the regenerated guardrail files carry the new @AIThreadSafe note)
  • Public API changes are intentional and carry the right version bump: recordLocked and holderAmong(mutex, waiting, holds) are new, @since 1.14.2. The detector has not been released yet.
  • Commits an agent authored carry the Co-Authored-By trailer
  • A new dependency was proposed with a reason (none added)
  • CI is green, or the failure is explained below

Anything reviewers should push back on

  • The agent now changes an argument of the user's call. It's the owner, and it changes only when the caller passed none. It's invisible to lock, unlock(null), tryLock and holdsLock. From kotlinx 1.8 on, nothing visible changes: the mutex's toString, and the message of an unlock(someOwner) after an owner-less lock, both print the owner, and the token prints as null, exactly as before. On kotlinx before 1.8, both print null where they printed LOCKED. The exception type is the same.
  • VERDICT. A finding now needs the mutex itself to confirm that the caller, or a coroutine suspended for it, holds the mutex, and the lock must then wait. That wait can only end through cancellation or a timeout. If you'd rather it stay at PROMPT for a release, revert the DetectorTrust row and the evidence line, and restore the hold with a new reason.
  • Locked Files Guard will need lock-override if DetectorTrust or the evidence file is locked.

🤖 Generated with Claude Code

PIsberg and others added 7 commits November 8, 2026 07:38
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

codecov Bot commented Nov 8, 2026

Copy link
Copy Markdown

@PIsberg
PIsberg merged commit 58e247d into main Nov 8, 2026
57 checks passed
@PIsberg
PIsberg deleted the fix/1221-mutex-holder-check branch November 8, 2026 11:56
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.

[P3] Kotlin Mutex detector keeps a stale holder after an unlock in an unwoven class and can report a false reentrancy

1 participant