Skip to content

Instantly share code, notes, and snippets.

@lukebakken
Last active May 28, 2026 16:32
Show Gist options
  • Select an option

  • Save lukebakken/1b0aded3214cc4b44bf5e1d63394ba30 to your computer and use it in GitHub Desktop.

Select an option

Save lukebakken/1b0aded3214cc4b44bf5e1d63394ba30 to your computer and use it in GitHub Desktop.

pika PR #1582 - Session Handoff Document

This document is written for an AI assistant picking up this work in a new session. Read every section before doing anything. The user (Luke Bakken, @lukebakken) will confirm when to start.


1. Essential rules - read these files first

Before any code or commit work, read these two files. They define mandatory workflow rules that Luke enforces strictly:

  • /home/lrbakken/genai/AGENTS.md - commit workflow (one 👍 per commit, never commit without approval, no em-dashes, etc.)
  • /home/lrbakken/genai/git/GIT.md - commit message formatting, PR body conventions, markdown rules, use --body-file for gh commands with backticks

The single most important rule: never commit anything without an explicit 👍 from Luke. Present the change, announce it is ready, wait for the thumbs-up, then commit. One logical fix = one commit = one 👍.


2. Repository context

Working directory: /home/lrbakken/development/pika/pika

Branch: feature/thread-safe-connection Remote tracking: gh-1582/feature/thread-safe-connection PR: pika/pika#1582 (currently DRAFT, milestone 1.5.0)

Original PR author: Dheeraj4103. Luke is the assignee and has been doing all the review and fixes in this session.

pika is in the top 5% of all PyPI downloads. Correctness matters enormously.

Run tests:

# Unit tests only (no broker needed)
python -m pytest tests/unit/thread_safe_connection_tests.py -q

# Acceptance tests (need RabbitMQ - see section 3)
python -m pytest tests/acceptance/thread_safe_connection_test.py -q

# Type checking
hatch run typecheck

3. RabbitMQ for acceptance tests

The acceptance tests require a broker on 127.0.0.1:5672 with guest/guest credentials. Start it with:

docker run --pull always --rm --publish 5672:5672 --publish 15672:15672 rabbitmq:4-management-alpine

The broker may not be running when you start the session. The acceptance test failures will say ConnectionRefusedError if it is down - that is not a code bug.


4. Key files

pika/adapters/thread_safe_connection.py   - the main implementation
tests/unit/thread_safe_connection_tests.py  - 51 unit tests (no broker)
tests/acceptance/thread_safe_connection_test.py  - 7 integration tests (broker required)
examples/basic_publisher_threaded.py      - usage example

5. What this PR adds

ThreadSafeConnection wraps SelectConnection and runs its IOLoop in a single dedicated background thread (the IOLoop thread). All channel write operations are routed through connection.ioloop.add_callback_threadsafe so that _tx_buffers is only ever touched from the IOLoop thread. This eliminates the IndexError: pop from an empty deque race that occurred when multiple threads called basic_publish on a shared raw connection (issues #1144 and #511).

ThreadSafeChannel wraps pika.channel.Channel:

  • Fire-and-forget methods (basic_publish, basic_ack, basic_nack, basic_reject): schedule via add_callback_threadsafe, return immediately, safe from any thread simultaneously.
  • Blocking methods (queue_declare, basic_qos, basic_consume, basic_cancel, close): block the calling thread on a threading.Event until the broker responds or the connection closes.

ThreadSafeConnection public API:

  • channel() - blocks until channel is open, returns ThreadSafeChannel
  • close(timeout=10) - blocks until IOLoop thread exits (or force-stops after timeout)
  • add_callback_threadsafe(callback) - schedule arbitrary work on the IOLoop thread
  • is_open, is_closed properties
  • Context manager support (with ThreadSafeConnection(...) as conn:)

6. Architecture details worth knowing

The _blocking_waiters mechanism: every blocking call appends a (threading.Event, list[BaseException | None]) tuple to _blocking_waiters while holding _channel_waiters_lock. When the connection closes (_on_connection_closed), when the IOLoop crashes (_run_ioloop except handler), or when the IOLoop is force-stopped (close() timeout path), all waiters are woken by setting their events with the error reason. This is how blocked threads are guaranteed to unblock even when things go wrong.

add_callback_threadsafe lives on connection.ioloop, not on SelectConnection. This was the critical production bug found by integration tests - all unit tests missed it because MagicMock auto-invents any attribute access. Every call site in the wrapper is self._connection.ioloop.add_callback_threadsafe(...).

_on_chan_close callbacks: each blocking channel method registers an add_on_close_callback inside the scheduled closure so that a channel-level broker error (e.g. 404 on passive declare) wakes the waiter. Pika provides no API to deregister individual close callbacks, so they accumulate for the lifetime of the channel. Each is guarded with if not ready.is_set() to make stale callbacks (from already-completed calls) unconditional no-ops.

on_message_callback wrapping: basic_consume wraps the user's callback so that the channel argument delivered to it is always the ThreadSafeChannel (self), never the raw pika.channel.Channel. This gives users a consistent API throughout.


7. Complete list of bugs found and fixed in this session

All of these are committed to the branch. They are listed in the order fixed.

Bug 0 (critical, found by integration tests): add_callback_threadsafe on wrong object

Root cause: Every call site used self._connection.add_callback_threadsafe(...). The method lives on self._connection.ioloop, not on SelectConnection. The wrapper was entirely non-functional in production. MagicMock silently invented the attribute in unit tests, masking the bug completely.

Fix: replaced all occurrences with self._connection.ioloop.add_callback_threadsafe(...). Updated unit tests to assert on mock_conn.ioloop.add_callback_threadsafe.

Commit: 67feebc

Bug 1: IOLoop crash before connection open hangs __init__ forever

Root cause: _run_ioloop's except handler woke _blocking_waiters but never set _connected_event. If the IOLoop crashed before _on_connection_open fired (e.g. during TCP setup), the constructor blocked on _connected_event.wait() indefinitely.

Fix: added to the except block:

if not self._connected_event.is_set():
    self._connect_error = exc
    self._connected_event.set()

Commit: 283a481

Bug 2: Channel-level broker errors hang blocking methods forever

Root cause: queue_declare, basic_qos, basic_consume, basic_cancel blocked on a threading.Event only woken by the success callback or a full connection close. A broker-side Channel.Close (404 on passive declare, parameter conflict, etc.) causes pika to drop all pending callbacks for that channel without firing them. _on_connection_closed is NOT called - only the channel closes. The calling thread waited forever.

Fix: registered add_on_close_callback(_on_chan_close) inside each scheduled closure. The close callback sets error[0] and calls ready.set().

Commit: a081f22

Bug 3: Scheduled callbacks had no try/except - exceptions killed the IOLoop

Root cause: _open (in channel()), _declare, _qos, _consume, _cancel, _close all called into the connection or channel without error handling. A wrong-state exception (e.g. channel() racing with close()) propagated out of ioloop.start() and killed the IOLoop instead of surfacing the error to the caller.

Fix: wrapped each scheduled closure body in try/except that sets error[0] and calls ready.set().

Commit: a081f22 (same commit as Bug 2)

Bug 4: close() from IOLoop thread could kill the IOLoop

Root cause: the IOLoop-thread fast path called self._connection.close() with no error handling. If the connection was already closing, ConnectionWrongStateError propagated from the callback and crashed the IOLoop.

Fix: wrapped in try/except matching the _safe_close pattern.

Commit: a081f22 (same commit as Bugs 2 and 3)

Bug 5: Force-stopped IOLoop left _closed_reason unset

Root cause: when close(timeout=N) expired and force-stopped the IOLoop via ioloop.stop(), _on_connection_closed was never called. _closed_reason stayed None; threads in _blocking_waiters at that moment hung forever; subsequent channel() calls also hung because the _closed_reason is None guard passed.

Fix: after the second join() in the force-stop path, if _closed_reason is None, set it to a forced-close exception and wake/clear all remaining _blocking_waiters.

Commit: aee948f

Bug 6: Stale _on_chan_close callbacks were not no-ops

Root cause: each completed blocking call left a registered _on_chan_close closure that would still execute work (setting error[0], calling ready.set()) when the channel eventually closed, even though the call had long since returned.

Fix: guarded each _on_chan_close with if not ready.is_set().

Commit: 8e35435

Issue 7 (documentation): IOLoop-thread warning was only on basic_consume

Root cause: the warning about heartbeat starvation and deadlock from calling blocking methods was buried in basic_consume's docstring, implying the danger was specific to message delivery. Any callback scheduled via add_callback_threadsafe has the same constraints.

Fix: moved the full warning to a dedicated "IOLoop-thread callbacks" section in the ThreadSafeChannel class docstring. Reduced basic_consume's docstring to a one-line cross-reference.

Commit: c2a4afd

Issue 8: on_message_callback received raw pika.channel.Channel, not ThreadSafeChannel

Root cause: basic_consume passed on_message_callback directly to the underlying raw channel, so the broker-delivered channel argument was always a raw pika.channel.Channel. Users who called channel.basic_ack(...) were bypassing the ThreadSafeChannel API, which was undocumented and inconsistent.

Fix: introduced _wrapped_callback inside _consume that substitutes self (the ThreadSafeChannel) for the raw channel argument before forwarding. No allocation needed - self is already the correct wrapper.

Commit: a83aad3


8. Current test state

  • 51 unit tests in tests/unit/thread_safe_connection_tests.py - all pass without a broker
  • 7 acceptance tests in tests/acceptance/thread_safe_connection_test.py - require broker
  • hatch run typecheck - clean on all 31 source files

The 7 acceptance tests are:

  1. TestBasicLifecycle - connect, channel, declare, publish, passive declare check
  2. TestConcurrentPublishing - 10 threads behind Barrier; regression for original _tx_buffers race
  3. TestConcurrentPublishAndConsume - basic_qos, basic_consume, 4 publisher threads, 20 messages all acked
  4. TestBrokerDropBlockedInChannel - ForwardServer intercept; blocked channel() unblocks within 10s after TCP drop
  5. TestBrokerDropBlockedInQueueDeclare - same for queue_declare()
  6. TestConcurrentClose - 5 threads call close() simultaneously; no crash or hang
  7. TestContextManager - connection closed on __exit__

9. Open discussion: making all callbacks safe and non-blocking

This is the topic that was being discussed when the session ended. Luke said he has ideas and wanted to hear the AI's thoughts first before sharing his own. The AI gave its thoughts; Luke said "yes, let's" to the general direction but had not yet shared his specific ideas. This thread needs to be picked up.

The problem

Any callback running on the IOLoop thread - on_message_callback and anything scheduled via add_callback_threadsafe - faces two dangers:

  1. Heartbeat starvation: slow work blocks the IOLoop, broker teardown follows
  2. Deadlock: calling any blocking ThreadSafeChannel method (queue_declare, basic_qos, basic_consume, basic_cancel, close) blocks the calling thread waiting for a broker response the IOLoop must process - since the IOLoop is blocked in the callback, it never processes the response

Currently the class docstring warns about this. That is necessary but not sufficient.

Options discussed

Option A - Fail fast on IOLoop-thread calls to blocking methods

Detect threading.current_thread() is self._wrapper._ioloop_thread at the top of each blocking method and raise RuntimeError immediately. Turns a silent permanent hang into a clear error. Zero API change, easy to implement. Should happen regardless of any other option chosen.

Option B - Executor-based dispatch for on_message_callback

Wrap the delivery callback to submit it to a concurrent.futures.ThreadPoolExecutor with a single worker (to preserve message ordering). The IOLoop hands off each delivery immediately and returns to I/O. The callback runs in the worker thread where blocking methods and slow operations are both safe. basic_ack etc. from the worker thread are already safe via add_callback_threadsafe.

Trade-off: changes the threading context of callbacks (shared state in callbacks now needs locks, but that is arguably correct). Adds a worker thread per consumer (or shared per channel/connection).

Option C - Optional executor parameter on basic_consume

basic_consume accepts an optional executor argument defaulting to None (current IOLoop-thread behaviour). Users who need blocking work pass executor=ThreadPoolExecutor(max_workers=1). The wrapper does executor.submit(_wrapped_callback, ...) instead of calling it directly. Opt-in, backwards-compatible, gives each consumer its own concurrency model.

AI's recommendation (stated at end of session): Option A (fail fast) as an immediate fix regardless, plus Option C (optional executor on basic_consume) as the proper solution. The executor approach is opt-in and does not impose overhead or threading-model changes on users who keep callbacks fast and non-blocking.

Luke said he has ideas - he was about to share them when the session ended. The next session should ask Luke to share his ideas before proceeding with any implementation.


10. Remaining known issues / possible future work

These were identified during review but not acted on:

  • _channel_waiters_lock is a slightly misleading name (it guards both _blocking_waiters and _closed_reason, not just channel waiters). Low priority.
  • No type annotations on ThreadSafeConnection.__init__ parameters (parameters, on_open_error_callback, on_close_callback). Low priority.
  • The PR is currently DRAFT. At some point Luke will mark it ready. Do not do this without being asked.

11. Commit workflow reminder

This is critical. Luke is strict about this.

  1. Make a change
  2. Run tests + typecheck to verify
  3. Check for trailing whitespace: grep -Pn ' +$' <file>
  4. Announce the change is ready
  5. Wait for an explicit 👍
  6. Only then commit

One logical fix = one commit = one 👍. Never batch multiple fixes into one commit unless Luke explicitly says to.

For commit messages with backticks or apostrophes, write to a temp file:

cat > /tmp/msg.txt << 'EOF'
Subject line

Body text with `backticks` and apostrophes.
EOF
git commit -F /tmp/msg.txt

12. PR body and title (current, as of this session)

Title: Add ThreadSafeConnection: fix _tx_buffers race and thread-safety bugs

The PR body was rewritten in this session and covers all the bugs, tests, and checklist items. It is up to date as of the last commit (a83aad3). If further significant changes are made, the PR body should be updated using:

cat > /tmp/pr_body.md << 'EOF'
... content ...
EOF
gh pr edit 1582 --repo pika/pika --body-file /tmp/pr_body.md

Never pass PR body text via inline --body with backticks - use --body-file.


13. User preferences

The normal Claude Code memory system is session-specific and will not be available in a new environment. All relevant preferences have been copied to:

/home/lrbakken/development/lukebakken/gist/pika-gh-1582/user-preferences.md

Read that file. Key points:

  • No hard-wrapping of prose in .md files (only commit message bodies wrap at 72 chars)
  • Read local source files with Read tool, not gh pr diff
  • Skip EnterWorktree if cwd is already under .claude/worktrees/
  • Git/markdown rules are in ~/genai/git/GIT.md - read at session start
  • Commit workflow rules are in ~/genai/AGENTS.md - read at session start

pika ThreadSafe* vs RabbitMQ Java Client — Comparison

Comparing pika's new ThreadSafeConnection / ThreadSafeChannel (PR #1582) against the RabbitMQ Java client on:

  1. Threading model (callback dispatch, work pools)
  2. API surface parity
  3. Error and timeout handling

Recovery is explicitly out of scope since pika does not implement it on ThreadSafeConnection.

References anchor pika at pika/adapters/thread_safe_connection.py and Java at rabbitmq-java-client/src/main/java/com/rabbitmq/client/{,impl}/. Line numbers are from the trees as read in this session.


1. Threading Model

1.1 I/O thread

Aspect pika ThreadSafeConnection Java AMQConnection
Background thread One per connection (pika-ioloop-N) One per connection (MainLoop)
Wakeup mechanism add_callback_threadsafe via IOLoop Frame reads block on socket; writes go through _channelLock
Heartbeat sender Same I/O thread (asyncio/select-driven) Separate ScheduledExecutorService (heartbeatExecutor)

Both designs put a single owner on socket I/O. pika reuses the IOLoop for heartbeats because the IOLoop is event-driven and never blocks on a single read; Java's MainLoop uses blocking reads, so heartbeats must be on a different thread.

Verdict: equivalent for the goal of single-owner socket access. pika's design is slightly simpler because asyncio gives it timer scheduling for free.

1.2 Consumer callback dispatch

This is the most important difference and the one most likely to bite users at scale.

Aspect pika Java
Where consumer callbacks run Per-channel ThreadPoolExecutor(max_workers=1) Shared ConsumerWorkService (default Executors.newFixedThreadPool(availableProcessors())) keyed by channel
Threads at scale (N channels) N dispatcher threads min(N, availableProcessors())
Per-channel ordering Guaranteed (single worker) Guaranteed (WorkPool<Channel, Runnable> makes a key dormant/ready/in-progress; only one block of work per key runs at a time)
Cross-channel parallelism Yes (different channels, different threads) Yes, but bounded by pool size
Executor injection Not exposed ConnectionFactory.setSharedExecutor(ExecutorService) allows the user to supply / share an executor
Backpressure on worker queue NoneThreadPoolExecutor's queue is unbounded Per-channel VariableLinkedBlockingQueue with MAX_QUEUE_LENGTH = 1000 items, optional workPoolTimeout (ms) for offer()
RPC-vs-delivery deadlock avoidance Not required: blocking RPCs are dispatched via add_callback_threadsafe to the I/O thread; the worker thread can issue them safely Yes: when an RPC is outstanding the work queue is setUnlimited(true) so the I/O thread never deadlocks pushing to a full bounded queue

Risks for pika

  • Memory growth at high channel counts. A user who opens 500 channels gets 500 idle Python threads. Each thread is ~8 MB stack on Linux by default, so this is ~4 GB of address space (mostly unused, but allocated). For the typical pika user this is irrelevant; for power users it is not.
  • No queue-length backpressure. A slow consumer callback combined with a high-rate publisher (auto-ack, mandatory=false, prefetch=∞) can grow the executor queue without bound and OOM the client. Java caps it.
  • Mixing publisher confirms with deliveries on the same channel. pika dispatches both through the per-channel single-worker pool. If a delivery callback is slow, the next Basic.Ack/Basic.Nack from the broker (also routed through the worker) is delayed behind it. Java dispatches confirm listeners directly on the I/O thread (callConfirmListeners in ChannelN.processAsync), so confirms are not blocked by slow consumers. This will surprise users who mix the two patterns and is worth calling out in the docstring.

Recommendation

For the 1.5.0 ship, the per-channel single-worker design is pragmatic and matches the design Bunny landed on. Two future improvements worth a tracking issue:

  1. Optional shared executor (closer to Java's model) for users who want one.
  2. Bounded executor queue with a configurable timeout, mirroring workPoolTimeout.

1.3 Listeners not dispatched on the I/O thread (Java's approach)

The Java client deliberately keeps a few callback types on the I/O thread because they are short and timing-sensitive:

  • ReturnListener (Basic.Return — unroutable mandatory publish)
  • ConfirmListener (Basic.Ack / Basic.Nack from publisher confirms)
  • BlockedListener (connection.blocked / unblocked)
  • ShutdownListener

pika's wrapper does not currently surface return, blocked, or shutdown listeners at all (see API gaps below). For confirms, pika moved them onto the per-channel work pool — safer for naive users (a slow confirm callback can't stall heartbeats) but slower than Java's hot path.

1.4 Shutdown of the work pool

Aspect pika Java
Per-channel pool shutdown _shutdown_pool() calls executor.shutdown(wait=True) (idempotent via _pool_shutdown flag). Called from ThreadSafeChannel.close() ConsumerDispatcher.handleShutdownSignal() posts a final shutdown runnable to the work pool, then calls workService.stopWork(channel)
Connection-level shutdown _shutdown_all_consumer_pools() runs after ioloop.start() returns (in _run_ioloop), to avoid the deadlock of running it on the I/O thread itself ConsumerWorkService.shutdown() calls workPool.unregisterAllKeys() (discards pending work) and shuts down the executor if private
Drains in-flight callbacks? Yes (shutdown(wait=True)) The shutdown notification runnable is itself queued, so prior items run first; remaining work is discarded by unregisterAllKeys (subtle: can drop items that were enqueued but not started)
Notifies consumers of shutdown? No (see API gap below) Yes, handleShutdownSignal is dispatched per consumer

The deadlock pika avoids — shutdown(wait=True) from inside the I/O thread that must run pool callbacks for wait=True to complete — is real and well-handled. The fact that pika never tells the user's on_message_callback "the channel is shutting down" is a real gap (see §2).


2. API Surface Parity

Comparing ThreadSafeChannel against com.rabbitmq.client.Channel and ThreadSafeConnection against com.rabbitmq.client.Connection. AMQP 0-9-1 method parity is good; what's missing is mostly the listener / observer surface.

2.1 ThreadSafeChannel — what's covered

  • basic_publish, basic_ack, basic_nack, basic_reject (fire-and-forget)
  • basic_qos, basic_consume, basic_cancel, basic_get, confirm_delivery
  • queue_declare, queue_delete, queue_purge, queue_bind, queue_unbind
  • exchange_declare, exchange_delete, exchange_bind, exchange_unbind
  • close, is_open, is_closed, channel_number

2.2 ThreadSafeChannel — what's missing vs Java

Java method Status Notes
abort() / abort(code, msg) Missing Close-and-swallow-errors. Useful when shutting down on an error path; common in user code. Easy add: close() wrapped in try/except.
basicRecover() / basicRecover(requeue) Missing Rare. AMQP-defined; tells broker to redeliver unacked messages. Pure RPC, fits _blocking_rpc.
txSelect() / txCommit() / txRollback() Missing AMQP transactions. Rarely used (publisher confirms preferred), but they are part of the spec.
waitForConfirms() / waitForConfirms(timeout) / waitForConfirmsOrDie() Missing Synchronous "wait until all outstanding publishes are confirmed". Java tracks unconfirmedSet internally. pika's confirm_delivery only delivers individual ack/nack callbacks. This is a real ergonomic gap — most Java tutorials use waitForConfirms.
addReturnListener / addReturnCallback Missing Required to handle mandatory=True returns. Currently a user can pass mandatory=True to basic_publish but has no way to receive the unroutable-message notification. Functional gap.
addConfirmListener (multiple) Partial confirm_delivery accepts exactly one ack_nack_callback. Java allows multiple listeners. Single is fine for 99% of users; multiple is rare.
addOnCancelCallback (server-initiated Basic.Cancel) Missing If the queue is deleted out from under a consumer, the broker sends Basic.Cancel. The user has no notification — their on_message_callback just stops receiving. Functional gap.
addShutdownListener (channel-level) Missing A way to be notified that this channel went down. The underlying pika Channel.add_on_close_callback is hidden by the wrapper.
setDefaultConsumer Missing Niche; handles deliveries with no matching consumer tag. Low priority.
*NoWait topology variants Missing Fire-and-forget topology operations (queueDeclareNoWait, etc.). Low priority — these are rarely used.
getNextPublishSeqNo Missing Sequence number of the next publish for confirm-tracking purposes. Required if a user wants to implement their own confirm-correlation.
getConnection Missing Way to get back to the parent connection from a channel. Easy add.
messageCount(queue) / consumerCount(queue) Missing Convenience wrappers around queue_declare(passive=True). Low priority.
basicConsume(...) overloads with DeliverCallback + CancelCallback + ConsumerShutdownSignalCallback N/A Idiomatic Java; not needed in Python.

2.3 ThreadSafeConnection — what's missing vs Java

Java method Status Notes
abort() / abort(code, msg, timeout) Missing Close-and-swallow. Easy add.
addBlockedListener / addBlockedCallback (connection.blocked / unblocked) Missing RabbitMQ sends Connection.Blocked when memory or disk alarms trip. Without this, a publisher hitting a blocked broker has no way to know. Functional gap, especially for production users.
addShutdownListener (connection-level) Partial on_close_callback is accepted in __init__ but cannot be added/removed afterward.
getServerProperties() / getClientProperties() Missing Exposes broker version, capabilities, etc. Useful for clients that gate features on broker version.
getChannelMax() / getFrameMax() / getHeartbeat() Missing Negotiated values. Low priority but trivial to expose.
getId() / setId() Missing Client-supplied connection identifier (shows up in management UI). Low priority.

2.4 What pika does that Java doesn't

  • ThreadSafeChannel is a context manager? — no, currently not. Java's Channel is AutoCloseable. Easy add.
  • pika's confirm_delivery runs the ack/nack callback on the per-channel worker pool. Java runs it on the I/O thread. pika's choice is safer for naive users but slower; documented above.
  • pika's _blocking_rpc registers add_on_close_callback on every RPC so a channel-level error wakes the waiter. Java has the same effect via the BlockingRpcContinuation.handleShutdownSignal path. Equivalent.

2.5 Recommended priority for follow-ups

High (functional gaps users will hit):

  • add_return_listener (or wire mandatory=True returns into a new callback) — basic_publish(mandatory=True) is currently broken in spirit
  • add_blocked_listener on the connection — production users running near memory limits need this
  • Server-initiated consumer cancel notification — silent message-stream stops are bad
  • wait_for_confirms / wait_for_confirms_or_die — most-cited use of confirms

Medium:

  • Channel-level abort() and connection-level abort() — common pattern in error paths
  • Channel-level shutdown listener (so users can clean up state when a channel dies)

Low:

  • tx_*, basic_recover, *_no_wait, set_default_consumer, broker properties accessors

3. Error and Timeout Handling

3.1 RPC timeouts

Aspect pika Java
Default RPC timeout 10 seconds (DEFAULT_RPC_TIMEOUT) 10 minutes (DEFAULT_CHANNEL_RPC_TIMEOUT = 600 000 ms)
Default 0 semantics None = wait forever 0 = wait forever (NO_RPC_TIMEOUT)
On timeout TimeoutError ChannelContinuationTimeoutException (wraps TimeoutException) plus cleanRpcChannelState() to clear the active RPC slot
Reply-type correlation None at the wrapper level _checkRpcResponseType (default-on) verifies reply class matches request, with per-tag matching for Basic.Consume/Basic.Cancel

Late-reply hazard after timeout

If queue_declare times out and the broker's Queue.DeclareOk arrives 11 seconds later, what happens?

  • pika ThreadSafe wrapper: the late-arriving callback runs on the I/O thread and sets the now-orphaned ready event. No external observer is waiting on it, so it is a no-op. However, the underlying pika.channel.Channel registered a one_shot=True callback for the reply class; that consumes the reply. If the user has not issued another RPC of the same type, the reply lands in the orphaned callback and is discarded. If the user issued another queue_declare in the meantime, the late reply belongs to the new call because pika's reply dispatch is keyed by reply class only. This is a real correctness hazard but it is inherent to pika's Channel machinery, not specific to the ThreadSafe wrapper. Java mitigates with _checkRpcResponseType.
  • Java: cleanRpcChannelState() clears _activeRpc after the timeout. Late replies arrive at handleCompleteInboundCommand and _activeRpc != null && !_activeRpc.canHandleReply(command) causes them to be discarded.

Recommendation: add a comment / docstring caveat noting that the safest action after a timeout is to close the channel.

Waiter-list leak on timeout

In _blocking_rpc:

if not ready.wait(timeout=timeout):
    raise TimeoutError(...)

self._unregister_waiter(ready, error)

_unregister_waiter is only called on the success path — when the timeout fires, the (ready, error) tuple is left in _blocking_waiters. Same pattern in basic_get and close.

Effect:

  • The leaked tuple is referenced until the connection closes, when _on_connection_closed (or the force-stop path) clears the list.
  • If a connection has thousands of timed-out RPCs over its lifetime, that's thousands of orphan tuples retained.

Severity: low — bounded by connection lifetime, the tuples are tiny — but worth fixing. Fix: wrap the wait in try/finally and call _unregister_waiter in finally. Same for basic_get and close.

3.2 Connection close handshake

Aspect pika Java
Default close timeout 10 s _rpcTimeout if set, else 10 s hardcoded for channel close
On timeout Force-stops IOLoop, sets _closed_reason = forced, wakes waiters, shuts down pools. Logs warning, does not raise. Throws ShutdownSignalException unless caller used abort()
Reentrancy close() can be called from the IOLoop thread itself; takes a fast path close() from IOLoop thread takes a quiescingTransmit shortcut
Re-entry from __exit__ with statement + close() works correctly Same

The pika design "log warning, don't raise" diverges from Java's "raise unless abort". Both are defensible: Java forces the user to opt into silent-close; pika makes silent-close the default for context-manager usage. Worth a docstring note.

3.3 Channel close

Aspect pika Java
Initiated-by-client signal ChannelClosedByClient filtered out (no raise on success) initiatedByApplication=true flag passed through processShutdownSignal
Close on broker error (ChannelClosedByBroker) _blocking_rpc raises broker reason via _on_chan_close Same shape
Close-during-pending-RPC The _on_chan_close handler in each _blocking_rpc catches it; waiter wakes up with broker reason notifyOutstandingRpc calls RpcWrapper.shutdown(signal) on every queued RPC

Behaviorally equivalent.

3.4 Consumer callback exceptions

Aspect pika Java
Behavior LOGGER.exception(...), continue dispatching subsequent deliveries connection.getExceptionHandler().handleConsumerException(...) (customizable: DefaultExceptionHandler, StrictExceptionHandler, ForgivingExceptionHandler)
Customizable No (logger only) Yes (ExceptionHandler interface)

For 1.5.0 the fixed-logger choice is fine; an exception_handler parameter could be added later for parity.

3.5 Queue backpressure failure mode

Aspect pika Java
Slow consumer + high publish rate Unbounded growth of ThreadPoolExecutor queue → OOM eventually Bounded queue (1000 items default); addWorkItem blocks (or times out per workPoolTimeout); back-pressures the I/O thread, which back-pressures the broker via TCP flow-control

Worth a future improvement (see §1.2).


4. Summary

4.1 Architectural alignment

pika's design choices are coherent with the Java client's at the high level:

  • One I/O thread, all socket access through it.
  • Consumer callbacks dispatched off the I/O thread.
  • Per-channel serial delivery ordering preserved.
  • Blocking client APIs implemented as "post a callback to I/O thread, wait on event".

4.2 Where pika diverges, by design

  • Per-channel executor (one thread per channel) instead of shared pool.
  • Confirm callbacks on the work pool instead of the I/O thread (safer).
  • 10-second default timeout instead of 10 minutes (closer to .NET; safer).
  • connection.close() timeout logs a warning rather than raising (context-manager friendly).

These are reasonable Python-idiomatic choices. None are bugs.

4.3 Real gaps worth tracking after 1.5.0

Functional / spec parity:

  1. mandatory=True returns have nowhere to go (no add_return_listener).
  2. No Connection.Blocked / Unblocked listener.
  3. No server-initiated consumer cancel notification.
  4. No wait_for_confirms / wait_for_confirms_or_die.
  5. No channel/connection abort() variant.

Reliability / scale:

  1. Unbounded executor queue (no backpressure on slow consumers).
  2. Per-channel thread overhead at high channel counts.
  3. Timed-out waiters leak in _blocking_waiters until connection close.
  4. No reply-type correlation; late RPC replies after a timeout could be misrouted to the next caller of the same RPC type (this is a pika-core limitation, not ThreadSafe-specific).

Polish:

  1. Customizable consumer exception handler (Java's ExceptionHandler).
  2. Channel/connection add_shutdown_listener callback registration after construction.
  3. Broker properties accessors (server_properties, client_properties).

4.4 Bottom-line assessment

For an initial 1.5.0 ship, the threading model is sound and the AMQP method coverage is good. The most user-visible gaps are around listeners that handle broker-initiated events (returns, blocked, server cancel), and the most operationally important reliability gap is the unbounded work queue under a slow consumer. Neither is a blocker for ship — both belong in the post-1.5.0 roadmap.

User Preferences and Workflow Rules

This file captures Luke Bakken's preferences accumulated over multiple sessions. It exists here because the normal memory system path is session-specific and will not be available in a new environment.


Feedback: No hard-wrapping in markdown files

Do not hard-wrap prose in markdown files (AGENTS.md, CONTRIBUTING.md, README.md, .md files of any kind). Let lines be as long as they need to be.

Why: GitHub and other renderers reflow markdown, so hard wraps at 72 or 80 chars produce no visual benefit and create noisy diffs.

How to apply: Only wrap at 72 characters in git commit message bodies. Everywhere else (markdown prose, GitHub text) - no wrapping.


Feedback: Use local source files instead of gh pr diff

When source code is available locally, read it with the Read tool rather than fetching it via gh pr diff.

Why: gh pr diff is a remote fetch and gives a diff view; reading the local file gives the full current state and is more useful for analysis.

How to apply: Any time the user has cloned a repo, check local paths first before reaching for the gh CLI to read code.


Feedback: Skip EnterWorktree when already in a worktree

Do not call EnterWorktree when the session's primary working directory is already under .claude/worktrees/. That path is already an isolated worktree; calling EnterWorktree again creates a new branch at the same path and edits land on that branch instead of the intended one.

How to apply: Before calling EnterWorktree, check whether cwd is already under .claude/worktrees/. If yes, skip the call and work in place.


Reference: Git and markdown conventions

~/genai/git/GIT.md contains the authoritative rules for:

  • Writing git commit messages (use -F for multi-line, wrap body at 72 chars)
  • Writing PR/issue bodies with gh (always use --body-file, never inline --body)
  • Markdown wrapping (no hard-wrap in any markdown; only commit bodies wrap)
  • Commit SHAs in GitHub markdown (no backticks - GitHub auto-links bare SHAs)
  • H1 headers in PR/comment bodies (use H2 or lower)
  • Em-dashes (never use in git or GitHub text)

~/genai/AGENTS.md contains the commit workflow rules (one 👍 per commit, etc.).

Read both files at the start of every session.


Reference: WSL DNS fix

If cargo-binstall or other tools hang on WSL: add dnsTunnelling=false to ~/.wslconfig (Windows-side) and restart WSL. Cause is hickory-dns DNSSEC validation failing when Windows DNS proxy strips DNSSEC records.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment